27 Commits

Author SHA1 Message Date
c3ae848f54 chore(merge): branch 'main' into command-algebra 2026-04-07 00:10:19 -07:00
1d5a04088b chore: reformat 2026-04-07 00:09:58 -07:00
a330412732 chore(merge): remote-tracking branch 'gh/main' 2026-04-07 00:02:29 -07:00
43015a41d8 Merge pull request #1 from fiddlerwoaroof/add-claude-github-actions-1775545235526
Add Claude Code GitHub Workflow
2026-04-07 00:00:59 -07:00
29f922aaca "Claude Code Review workflow" 2026-04-07 00:00:39 -07:00
ee6970fed0 "Claude PR Assistant workflow" 2026-04-07 00:00:37 -07:00
880c471ff6 refactor(cmd): introduce command algebra with Binding::Sequence and unified primitives
Add Binding::Sequence to keymap for composing commands, then use it
and parameterization to eliminate redundant command structs:

- Unify MoveSelection/JumpToEdge/ScrollRows/PageScroll into Move
- Merge ToggleGroupUnderCursor + ToggleColGroupUnderCursor → ToggleGroupAtCursor
- Merge CommitCellEdit + CommitAndAdvanceRight → CommitAndAdvance
- Merge CycleAxisForTile + SetAxisForTile → TileAxisOp
- Merge ViewBackCmd + ViewForwardCmd → ViewNavigate
- Delete SearchAppendChar/SearchPopChar (reuse AppendChar/PopChar with "search")
- Replace SaveAndQuit/OpenRecordRow with keymap sequences
- Extract commit_add_from_buffer helper for CommitCategoryAdd/CommitItemAdd
- Add algebraic law tests (idempotence, involution, associativity)

https://claude.ai/code/session_01Y9X6VKyZAW3xo1nfThDRYU
2026-04-07 06:48:34 +00:00
e166049bae chore: clippy 2026-04-06 23:21:36 -07:00
2c05b64d51 misc 2026-04-06 23:21:06 -07:00
86ac30c11d refactor(ui): ensure layout is rebuilt and use Rc for drill state
Update UI components and effects to ensure layout is rebuilt when necessary.

- Rebuild layout in `EnterEditAtCursor` effect
- Use `Rc` for sharing records in `StartDrill` effect
- Improve formatting in `SetDrillPendingEdit` effect
- Update test case in `App` to use multi-line method calls

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
7cb09c11da docs(cmd): update ToggleRecordsMode documentation
Update documentation for ToggleRecordsMode to clarify its behavior with the view
stack.

- Clarify that entering records mode creates a `_Records` view
- Clarify that leaving records mode navigates back to the previous view

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
3847cb4bfa chore: update .gitignore
Update .gitignore to include patch and improv files.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
3f76d98816 style: cleanup formatting and code style across the project
Clean up formatting and code style across the project.

- Remove unnecessary whitespace and empty lines in `src/model/cell.rs` ,
  `src/model/types.rs` , and `src/draw.rs` .
- Reformat long lines and function calls in `src/command/cmd.rs` ,
  `src/ui/grid.rs` , and `src/ui/tile_bar.rs` for better readability.
- Consolidate imports and simplify expressions in `src/command/keymap.rs` and
  `src/ui/app.rs` .

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
1c463a9f06 refactor(ui): improve TileBar rendering and width calculation
Update TileBar to use UnicodeWidthStr for accurate text width calculation and
improve axis display.

- Use `unicode_width::UnicodeWidthStr` for calculating label and hint widths
- Move `axis_display` to a static method on `TileBar`
- Update axis symbols for better visual clarity (e.g., '|', '-', '=', '.')
- Clear the tile bar area before rendering to prevent stale characters

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
4739e667e7 refactor(ui): use pre-computed layout in GridWidget
Refactor GridWidget and other UI components to use the pre-computed layout from
App instead of re-creating it on every render. This improves performance and
ensures consistency.

- Update GridWidget to take a reference to GridLayout
- Update GridWidget::new and render to use the provided layout
- Update App::cmd_context and other call sites to pass the layout
- Update tests to provide the layout to GridWidget

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
91eaa7046a refactor(view): use Rc for sharing records in GridLayout
Refactor GridLayout to use Rc for sharing records and simplify its structure.
This improves performance when cloning the layout and ensures consistent data
access.

- Use Rc<Vec<(CellKey, CellValue)>> for records in GridLayout
- Update with_frozen_records and other methods to handle Rc
- Simplify layout construction and sorting logic

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:40 -07:00
7ceadaf821 refactor(ui): improve layout management and record sharing in App
Refactor App and DrillState to use Rc for sharing records, and introduce
rebuild_layout() to manage GridLayout lifecycle. This ensures the layout is
consistently updated when the model or drill state changes.

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

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
2fc4ea72a4 feat(cmd): improve ToggleRecordsMode using view stack navigation
Improve ToggleRecordsMode by using the view stack for navigation. Instead of
manually calculating axes, it now creates a `_Records` view and switches to it,
or navigates back to the previous view if already in records mode.

- Use `ctx.layout.is_records_mode()` to detect state
- Use `effect::ViewBack` to exit records mode
- Use `effect::CreateView` and `effect::SwitchView` to enter records mode
- Simplify axis setting logic for records mode

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
db1aa4c6a5 refactor(cmd): delegate cell information to GridLayout in CmdContext
Refactor CmdContext to use GridLayout for cell-related information instead of
storing redundant fields. This simplifies the context structure and ensures
commands always use the most up-to-date layout information.

- Add layout field to CmdContext
- Remove redundant row_count, col_count, cell_key, and none_cats fields
- Implement helper methods on CmdContext to delegate to layout
- Update all command implementations and tests to use the new methods

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q5_K_XL)
2026-04-06 23:18:39 -07:00
7c7fbd3289 refactor(ui): simplify panel mode mapping and add scroll tests
Adds a `mode()` method to the `Panel` enum to map panels to their
corresponding `AppMode`. Simplifies `TogglePanelAndFocus` in `cmd.rs`
to use this method instead of a manual match block.

Also adds regression tests in `app.rs` to verify that viewport
scrolling now correctly handles small terminal heights.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
7d8c4a37a2 feat(cmd): unify jump-to-edge and fix viewport scrolling
Replaces separate jump-to-edge commands with a unified `JumpToEdge`
command. Simplifies the command registry using a macro and updates
`ScrollRows` to use a shared `viewport_effects` helper for consistent
scrolling behavior.

This fixes a bug where viewport scrolling was based on a hardcoded
constant (20) instead of the actual visible row count.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
5df3f87bc1 refactor(view): unify cell handling for records and pivot modes
Unifies cell text retrieval and formatting across pivot and records modes.
Introduces `GridLayout::display_text` to centralize how cell content is
resolved, reducing duplication in `GridWidget` and `export_csv`.
Moves formatting logic from `src/ui/grid.rs` to a new dedicated `src/format.rs`
module to improve reusability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:56:47 -07:00
95f2d00ae2 test: update cmd context in tests
Update the test context setup in `src/command/cmd.rs` to use the new
`display_value` field instead of the removed `records_col` and `records_value`
fields.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
fc2baf0e7e style: simplify panel titles
Clean up UI panel titles by removing redundant keyboard hints that are
already present in the app's status bar or are no longer needed.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
910e9233a3 fix: use precise column widths for viewport scrolling
Improve grid viewport calculations by using actual column widths instead of
rough estimates. This ensures that the viewport scrolls correctly when the
cursor moves past the visible area.

- Move column width and visible column calculations to public functions in
  `src/ui/grid.rs`.
- Update `App::cmd_context` to use these precise calculations for `visible_cols`
  .
- Add a regression test to verify that `col_offset` scrolls when cursor moves
  past visible columns.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:30 -07:00
e193362de8 feat: add page scrolling, open-row, and tab-advance
Add new navigation and editing capabilities to improve data entry and
browsing efficiency.

- Implement `PageScroll` command for PageUp/PageDown navigation.
- Implement `OpenRecordRow` command (Vim-style 'o') to add a row and enter edit
  mode.
- Implement `CommitAndAdvanceRight` command for Excel-style Tab navigation.
- Add keybindings for Home, End, PageUp, PageDown, and Tab.
- Update UI hints to reflect new Tab functionality.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:29 -07:00
4d82fd434a refactor!: unify records and pivot mode cell handling
Refactor records mode to use synthetic CellKeys (_Index, _Dim) for all columns,
allowing uniform handling of display values and edits across both pivot and
records modes.

- Introduce `synthetic_record_info` to extract metadata from synthetic keys.
- Update `GridLayout::cell_key` to return synthetic keys in records mode.
- Add `GridLayout::resolve_display` to handle value resolution for synthetic
  keys.
- Replace `records_col` and `records_value` in `CmdContext` with a unified
  `display_value`.
- Update `EditOrDrill` and `AddRecordRow` to use synthetic key detection.
- Refactor `CommitCellEdit` to use a shared `commit_cell_value` helper.

BREAKING CHANGE: CmdContext fields `records_col` and `records_value` are replaced by
`display_value` .
Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/gemma-4-31B-it-GGUF:UD-Q5_K_XL)
2026-04-06 21:21:29 -07:00
19 changed files with 1487 additions and 897 deletions

View File

@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

4
.gitignore vendored
View File

@ -8,3 +8,7 @@ symbols.json
profile.json
profile.json.gz
bench/*.txt
# Added by git-smart-commit
*.patch
*.improv

File diff suppressed because it is too large Load Diff

View File

@ -72,6 +72,8 @@ pub enum Binding {
},
/// A prefix sub-keymap (Emacs-style).
Prefix(Arc<Keymap>),
/// A sequence of commands executed in order, concatenating their effects.
Sequence(Vec<(&'static str, Vec<String>)>),
}
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
@ -121,6 +123,17 @@ impl Keymap {
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
}
/// Bind a key to a sequence of commands (executed in order).
pub fn bind_seq(
&mut self,
key: KeyCode,
mods: KeyModifiers,
steps: Vec<(&'static str, Vec<String>)>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Sequence(steps));
}
/// Bind a catch-all for any Char key.
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
self.bindings
@ -143,8 +156,7 @@ impl Keymap {
.or_else(|| {
// Retry Char keys without modifiers (shift is implicit in the char)
if matches!(key, KeyCode::Char(_)) && mods != KeyModifiers::NONE {
self.bindings
.get(&KeyPattern::Key(key, KeyModifiers::NONE))
self.bindings.get(&KeyPattern::Key(key, KeyModifiers::NONE))
} else {
None
}
@ -174,6 +186,14 @@ impl Keymap {
Some(cmd.execute(ctx))
}
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
Binding::Sequence(steps) => {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
for (name, args) in steps {
let cmd = registry.interactive(name, args, ctx).ok()?;
effects.extend(cmd.execute(ctx));
}
Some(effects)
}
}
}
}
@ -266,10 +286,14 @@ impl KeymapSet {
normal.bind(KeyCode::Char('G'), none, "jump-last-row");
normal.bind(KeyCode::Char('0'), none, "jump-first-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
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::PageDown, none, "page-scroll", vec!["1".into()]);
normal.bind_args(KeyCode::PageUp, none, "page-scroll", vec!["-1".into()]);
// Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell");
@ -357,7 +381,11 @@ impl KeymapSet {
// 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");
normal.bind_seq(
KeyCode::Char('o'),
none,
vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])],
);
// Records mode toggle and prune toggle
normal.bind(KeyCode::Char('R'), none, "toggle-records-mode");
@ -381,7 +409,11 @@ impl KeymapSet {
normal.bind_prefix(KeyCode::Char('y'), none, Arc::new(y_map));
let mut z_map = Keymap::new();
z_map.bind(KeyCode::Char('Z'), none, "save-and-quit");
z_map.bind_seq(
KeyCode::Char('Z'),
none,
vec![("save", vec![]), ("force-quit", vec![])],
);
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
set.insert(ModeKey::Normal, Arc::new(normal));
@ -422,9 +454,24 @@ 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()]);
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 ───────────────────────────────────────────────
@ -505,9 +552,24 @@ 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()]);
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 ──────────────────────────────────────────────────
@ -560,6 +622,7 @@ impl KeymapSet {
let mut ed = Keymap::new();
ed.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
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_any_char("append-char", vec!["edit".into()]);
set.insert(ModeKey::Editing, Arc::new(ed));
@ -630,8 +693,8 @@ impl KeymapSet {
let mut sm = Keymap::new();
sm.bind(KeyCode::Esc, none, "exit-search-mode");
sm.bind(KeyCode::Enter, none, "exit-search-mode");
sm.bind(KeyCode::Backspace, none, "search-pop-char");
sm.bind_any_char("search-append-char", vec![]);
sm.bind_args(KeyCode::Backspace, none, "pop-char", vec!["search".into()]);
sm.bind_any_char("append-char", vec!["search".into()]);
set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard ────────────────────────────────────────────────

View File

@ -257,6 +257,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(
GridWidget::new(
&app.model,
&app.layout,
&app.mode,
&app.search_query,
&app.buffers,
@ -282,11 +283,7 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
Some((format!("edit: {buf}"), Color::Green))
}
AppMode::FormulaEdit { .. } => {
let buf = app
.buffers
.get("formula")
.map(|s| s.as_str())
.unwrap_or("");
let buf = app.buffers.get("formula").map(|s| s.as_str()).unwrap_or("");
Some((format!("formula: {buf}"), Color::Cyan))
}
AppMode::CategoryAdd { .. } => {
@ -302,11 +299,7 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
Some((format!("add item to {category}: {buf}"), Color::Green))
}
AppMode::ExportPrompt { .. } => {
let buf = app
.buffers
.get("export")
.map(|s| s.as_str())
.unwrap_or("");
let buf = app.buffers.get("export").map(|s| s.as_str()).unwrap_or("");
Some((format!("export path: {buf}"), Color::Yellow))
}
_ => None,
@ -349,7 +342,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_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);

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 draw;
mod format;
mod formula;
mod import;
mod model;

View File

@ -93,7 +93,6 @@ impl std::fmt::Display for CellValue {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct InternedKey(pub Vec<(Symbol, Symbol)>);
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
/// to implement the `Serialize`-as-string requirement for JSON object keys.
#[derive(Debug, Clone, Default)]
@ -180,9 +179,7 @@ impl DataStore {
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
self.cells
.iter()
.map(|(k, v)| (self.to_cell_key(k), v))
self.cells.iter().map(|(k, v)| (self.to_cell_key(k), v))
}
pub fn remove(&mut self, key: &CellKey) {

View File

@ -132,8 +132,7 @@ impl Model {
self.data.remove(&k);
}
// Remove formulas targeting this category
self.formulas
.retain(|f| f.target_category != name);
self.formulas.retain(|f| f.target_category != name);
}
/// Remove an item from a category and all cells that reference it.

View File

@ -458,17 +458,7 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
out.push(',');
}
let row_values: Vec<String> = (0..layout.col_count())
.map(|ci| {
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()
}
})
.map(|ci| layout.display_text(model, ri, ci, false, 0))
.collect();
out.push_str(&row_values.join(","));
out.push('\n');

View File

@ -5,23 +5,25 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::rc::Rc;
use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet};
use crate::import::wizard::ImportWizard;
use crate::model::cell::CellValue;
use crate::model::Model;
use crate::persistence;
use crate::ui::grid::{
compute_col_widths, compute_row_header_width, compute_visible_cols, parse_number_format,
};
use crate::view::GridLayout;
/// Drill-down state: frozen record snapshot + pending edits that have not
/// yet been applied to the model.
#[derive(Debug, Clone, Default)]
pub struct DrillState {
/// Frozen snapshot of records shown in the drill view.
pub records: Vec<(
crate::model::cell::CellKey,
crate::model::cell::CellValue,
)>,
/// Frozen snapshot of records shown in the drill view (Rc for cheap cloning).
pub records: Rc<Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)>>,
/// Pending edits keyed by (record_idx, column_name) → new string value.
/// column_name is either "Value" or a category name.
pub pending_edits: std::collections::HashMap<(usize, String), String>,
@ -100,11 +102,18 @@ pub struct App {
pub buffers: HashMap<String, String>,
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
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,
}
impl App {
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 {
model,
file_path,
@ -131,17 +140,26 @@ impl App {
expanded_cats: std::collections::HashSet::new(),
buffers: HashMap::new(),
transient_keymap: None,
layout,
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<'_> {
let view = self.model.active_view();
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone());
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
let layout = &self.layout;
let (sel_row, sel_col) = view.selected;
CmdContext {
model: &self.model,
layout,
mode: &self.mode,
selected: view.selected,
row_offset: view.row_offset,
@ -158,34 +176,39 @@ impl App {
cat_panel_cursor: self.cat_panel_cursor,
view_panel_cursor: self.view_panel_cursor,
tile_cat_idx: self.tile_cat_idx,
cell_key: layout.cell_key(sel_row, sel_col),
row_count: layout.row_count(),
col_count: layout.col_count(),
none_cats: layout.none_cats.clone(),
view_back_stack: self.view_back_stack.clone(),
view_forward_stack: self.view_forward_stack.clone(),
records_col: if layout.is_records_mode() {
Some(layout.col_label(sel_col))
view_back_stack: &self.view_back_stack,
view_forward_stack: &self.view_forward_stack,
display_value: {
let key = layout.cell_key(sel_row, sel_col);
if let Some(k) = &key {
if let Some((idx, dim)) = crate::view::synthetic_record_info(k) {
self.drill_state
.as_ref()
.and_then(|s| s.pending_edits.get(&(idx, dim)).cloned())
.or_else(|| layout.resolve_display(k))
.unwrap_or_default()
} else {
None
},
records_value: if layout.is_records_mode() {
// Check pending edits first, then fall back to original
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))
self.model
.get_cell(k)
.map(|v| v.to_string())
.unwrap_or_default()
}
} else {
None
String::new()
}
},
// 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),
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,
}
@ -195,6 +218,7 @@ impl App {
for effect in effects {
effect.apply(self);
}
self.rebuild_layout();
}
/// True when the model has no categories yet (show welcome screen)
@ -203,6 +227,8 @@ impl App {
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
self.rebuild_layout();
// Transient keymap (prefix key sequence) takes priority
if let Some(transient) = self.transient_keymap.take() {
let effects = {
@ -247,7 +273,7 @@ impl App {
pub fn hint_text(&self) -> &'static str {
match &self.mode {
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::FormulaEdit { .. } => "Enter:save Esc:cancel — type: Name = expression",
AppMode::CategoryPanel => "jk:nav Space:cycle-axis n:new-cat a:add-items d:delete Esc:back",
@ -371,6 +397,190 @@ mod tests {
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]
fn command_mode_buffer_cleared_on_reentry() {
use crossterm::event::KeyEvent;

View File

@ -49,7 +49,7 @@ impl<'a> Widget for CategoryPanel<'a> {
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
let (border_color, title) = if is_active {
(Color::Cyan, " Categories n:new d:del Space:axis ")
(Color::Cyan, " Categories ")
} else {
(Color::DarkGray, " Categories ")
};

View File

@ -96,16 +96,12 @@ impl Effect for RemoveFormula {
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()
};
app.rebuild_layout();
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 {
@ -406,7 +402,7 @@ pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
impl Effect for StartDrill {
fn apply(&self, app: &mut App) {
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(),
});
}
@ -480,9 +476,10 @@ pub struct SetDrillPendingEdit {
impl Effect for SetDrillPendingEdit {
fn apply(&self, app: &mut App) {
if let Some(drill) = &mut app.drill_state {
drill
.pending_edits
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
drill.pending_edits.insert(
(self.record_idx, self.col_name.clone()),
self.new_value.clone(),
);
}
}
}
@ -838,6 +835,16 @@ pub enum Panel {
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 {
fn apply(&self, app: &mut App) {
match self.panel {

View File

@ -6,7 +6,6 @@ use ratatui::{
};
use unicode_width::UnicodeWidthStr;
use crate::model::cell::CellValue;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout};
@ -23,6 +22,7 @@ const GROUP_COLLAPSED: &str = "▶";
pub struct GridWidget<'a> {
pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode,
pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>,
@ -32,6 +32,7 @@ pub struct GridWidget<'a> {
impl<'a> GridWidget<'a> {
pub fn new(
model: &'a Model,
layout: &'a GridLayout,
mode: &'a AppMode,
search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>,
@ -39,6 +40,7 @@ impl<'a> GridWidget<'a> {
) -> Self {
Self {
model,
layout,
mode,
search_query,
buffers,
@ -46,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) {
let view = self.model.active_view();
let frozen = self.drill_state.map(|s| s.records.clone());
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
let layout = self.layout;
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
@ -71,56 +59,9 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// ── 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![0u16; n];
// Measure column header labels
for ci in 0..n {
let header = layout.col_label(ci);
let w = header.width() as u16;
if w > widths[ci] {
widths[ci] = 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;
}
}
}
}
}
// +1 for gap between columns
widths
.into_iter()
.map(|w| (w + 1).max(MIN_COL_WIDTH).min(MAX_COL_WIDTH))
.collect()
};
let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals);
// ── Adaptive row header widths ───────────────────────────────
// Measure the widest label at each row-header level.
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
@ -187,9 +128,7 @@ impl<'a> GridWidget<'a> {
v
};
let col_x_at = |ci: usize| -> u16 {
area.x
+ row_header_width
+ col_x[ci].saturating_sub(col_x[col_offset])
area.x + 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(&MIN_COL_WIDTH) };
@ -240,7 +179,11 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!("{:<width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
format!(
"{:<width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
),
group_style,
);
}
@ -292,7 +235,11 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!("{:>width$}", truncate(&label, cw.saturating_sub(1)), width = cw),
format!(
"{:>width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
),
styled,
);
}
@ -410,23 +357,17 @@ impl<'a> GridWidget<'a> {
}
let cw = col_w_at(ci) as usize;
let (cell_str, value) = if layout.is_records_mode() {
let s = self.records_cell_text(&layout, ri, ci);
// In records mode the value is a string, not aggregated
let v = if !s.is_empty() {
Some(crate::model::cell::CellValue::Text(s.clone()))
// Check pending drill edits first, then use display_text
let cell_str = if let Some(ds) = self.drill_state {
let col_name = layout.col_label(ci);
ds.pending_edits
.get(&(ri, col_name))
.cloned()
.unwrap_or_else(|| {
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
})
} else {
None
};
(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)
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
};
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
@ -453,13 +394,13 @@ impl<'a> GridWidget<'a> {
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row {
let fg = if value.is_none() {
let fg = if cell_str.is_empty() {
Color::DarkGray
} else {
Color::White
};
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if value.is_none() {
} else if cell_str.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
@ -561,9 +502,9 @@ impl<'a> Widget for GridWidget<'a> {
block.render(area, buf);
// Page axis bar
let layout = GridLayout::new(self.model, self.model.active_view());
if !layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = layout
if !self.layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = self
.layout
.page_coords
.iter()
.map(|(cat, sel)| format!("{cat} = {sel}"))
@ -588,53 +529,123 @@ impl<'a> Widget for GridWidget<'a> {
}
}
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(),
}
}
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)
}
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..]))
/// Compute adaptive column widths for pivot mode (header labels + cell values).
/// Header widths use the widest *individual* level label (not the joined
/// multi-level string), matching how the grid renderer draws each level on
/// its own row with repeat-suppression.
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
let data_col_items: Vec<&Vec<String>> = layout
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} 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(',');
None
}
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 {
let w = s.width();
if w <= max_width {
@ -666,6 +677,7 @@ mod tests {
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::GridLayout;
// ── Helpers ───────────────────────────────────────────────────────────────
@ -674,7 +686,8 @@ mod tests {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
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
}
@ -720,10 +733,7 @@ mod tests {
// 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.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
}
}
m

View File

@ -4,20 +4,12 @@ use ratatui::{
style::{Color, Modifier, Style},
widgets::Widget,
};
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("", Color::Green),
Axis::Column => ("", Color::Blue),
Axis::Page => ("", Color::Magenta),
Axis::None => ("", Color::DarkGray),
}
}
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
@ -32,10 +24,26 @@ impl<'a> TileBar<'a> {
tile_cat_idx,
}
}
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("|", Color::Green),
Axis::Column => ("-", Color::Blue),
Axis::Page => ("=", Color::Magenta),
Axis::None => (".", Color::DarkGray),
}
}
}
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
// Clear the line to avoid stale characters from previous renders
buf.set_string(
area.x,
area.y,
" ".repeat(area.width as usize),
Style::default(),
);
let view = self.model.active_view();
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
@ -50,7 +58,7 @@ impl<'a> Widget for TileBar<'a> {
let cat_names: Vec<&str> = self.model.category_names();
for (i, cat_name) in cat_names.iter().enumerate() {
let (axis_symbol, axis_color) = axis_display(view.axis_of(cat_name));
let (axis_symbol, axis_color) = TileBar::axis_display(view.axis_of(cat_name));
let label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i);
@ -63,22 +71,23 @@ impl<'a> Widget for TileBar<'a> {
Style::default().fg(axis_color)
};
if x + label.len() as u16 > area.x + area.width {
let label_w = label.width() as u16;
if x + label_w > area.x + area.width {
break;
}
buf.set_string(x, area.y, &label, style);
x += label.len() as u16;
x += label_w;
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.len() as u16 <= area.x + area.width {
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.len() as u16 <= area.x + area.width {
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}

View File

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

View File

@ -1,7 +1,17 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
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.
///
/// `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.
pub none_cats: Vec<String>,
/// In records mode: the filtered cell list, one per row.
/// None for normal pivot views.
pub records: Option<Vec<(CellKey, CellValue)>>,
/// None for normal pivot views. Rc for cheap sharing.
pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
}
impl GridLayout {
@ -40,12 +50,11 @@ impl GridLayout {
pub fn with_frozen_records(
model: &Model,
view: &View,
frozen_records: Option<Vec<(CellKey, CellValue)>>,
frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
) -> Self {
let mut layout = Self::new(model, view);
if layout.is_records_mode() {
if let Some(records) = frozen_records {
// Re-build with the frozen records instead
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
@ -148,7 +157,7 @@ impl GridLayout {
.collect()
};
// Sort for deterministic ordering
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
// Synthesize row items: one per record, labeled with its index
let row_items: Vec<AxisEntry> = (0..records.len())
@ -175,7 +184,7 @@ impl GridLayout {
row_items,
col_items,
none_cats,
records: Some(records),
records: Some(Rc::new(records)),
}
}
@ -220,14 +229,10 @@ impl GridLayout {
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)
has_value[ri][ci] = self
.cell_key(ri, ci)
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
.is_some()
};
.is_some();
}
}
@ -297,7 +302,7 @@ impl GridLayout {
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
self.row_items = new_row_items;
self.records = Some(new_records);
self.records = Some(Rc::new(new_records));
}
}
@ -352,18 +357,57 @@ impl GridLayout {
.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
/// page-axis filter. Returns None if row or col is out of bounds.
/// In records mode: returns the real underlying CellKey when the column
/// is "Value" (editable); returns None for coord columns (read-only).
/// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
if let Some(records) = &self.records {
// Records mode: only the Value column maps to a real, editable cell.
if self.col_label(col) == "Value" {
return records.get(row).map(|(k, _)| k.clone());
} else {
if self.records.is_some() {
let records = self.records.as_ref().unwrap();
if row >= records.len() {
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
.row_items
@ -527,7 +571,7 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)]
mod tests {
use super::{AxisEntry, GridLayout};
use super::{synthetic_record_info, AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
@ -566,10 +610,7 @@ mod tests {
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()),
]),
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
CellValue::Number(1.0),
);
@ -592,40 +633,73 @@ mod tests {
}
#[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 v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
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();
// cell_key should be Some for Value column
let key = layout.cell_key(0, value_col);
assert!(key.is_some(), "Value column should be editable");
// cell_key should be None for coord columns
let key = layout.cell_key(0, value_col).unwrap();
assert_eq!(key.get("_Index"), Some("0"));
assert_eq!(key.get("_Dim"), Some("Value"));
let region_col = cols.iter().position(|c| c == "Region").unwrap();
assert!(
layout.cell_key(0, region_col).is_none(),
"Region column should not be editable"
);
let key = layout.cell_key(0, region_col).unwrap();
assert_eq!(key.get("_Index"), Some("0"));
assert_eq!(key.get("_Dim"), Some("Region"));
}
#[test]
fn records_mode_cell_key_maps_to_real_cell() {
fn records_mode_resolve_display_returns_values() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
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();
// The CellKey at (0, Value) should look up a real cell value
let key = layout.cell_key(0, value_col).unwrap();
let val = m.evaluate(&key);
assert!(val.is_some(), "cell_key should resolve to a real cell");
let display = layout.resolve_display(&key);
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 {

View File

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