75 Commits

Author SHA1 Message Date
94bc3ca282 feat(ui): improve row selection highlighting in grid
Add a subtle dark-gray background color constant for row highlighting.

Apply the highlight background across the entire selected row area,
including gaps between columns and the margin after the last column.

Apply the highlight background to individual cells in the selected row,
using DarkGray for empty values and White for non-empty values.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 12:30:15 -07:00
f56ca2c66a feat(ui): add dynamic column widths for records mode
Implement dynamic column widths for the grid widget when in records mode.

In records mode, each column width is computed based on the widest
content (pending edit, record value, or header label), with a minimum
of 6 characters and maximum of 32. Pivot mode continues to use fixed
10-character column widths.

The rendering code has been updated to use the computed column widths
and x-offsets for all grid elements: headers, data cells, and totals.
Note that the total row is now only displayed in pivot mode, as it
is not meaningful in records mode.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 11:53:37 -07:00
78df3a4949 feat: add records-mode drill-down with staged edits
Introduce records-mode drill-down functionality that allows users to
edit individual records without immediately modifying the underlying model.

Key changes:
- Added DrillState struct to hold frozen records snapshot and pending edits
- New effects: StartDrill, ApplyAndClearDrill, SetDrillPendingEdit
- Extended CmdContext with records_col and records_value for records mode
- CommitCellEdit now stages edits in pending_edits when in records mode
- DrillIntoCell captures a snapshot before switching to drill view
- GridLayout supports frozen records for stable view during edits
- GridWidget renders with drill_state for pending edit display

In records mode, edits are staged and only applied to the model when
the user navigates away or commits. This prevents data loss and allows
batch editing of records.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 11:45:36 -07:00
19645a34cf feat: add records mode (long-format view) for drill-down
Implement records mode (long-format view) when drilling into aggregated cells.

Key changes:
- DrillIntoCell now creates views with _Index on Row and _Dim on Column
- GridLayout detects records mode and builds a records list instead of
  cross-product row/col items
- Added records_display() to render individual cell values in records mode
- GridWidget and CSV export updated to handle records mode rendering
- category_names() now includes virtual categories (_Index, _Dim)
- Tests updated to reflect virtual categories counting toward limits

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 11:10:41 -07:00
67041dd4a5 feat: add view history navigation and drill-into-cell
Add view navigation history with back/forward stacks (bound to < and >).

Introduce CategoryKind enum to distinguish regular categories from
virtual ones (_Index, _Dim) that are synthesized at query time.

Add DrillIntoCell command that creates a drill view showing raw data
for an aggregated cell, expanding categories on Axis::None into Row
and Column axes while filtering by the cell's fixed coordinates.

Virtual categories default to Axis::None and are automatically added
to all views when the model is initialized.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 10:57:28 -07:00
b2d633eb7d chore: update gitignore 2026-04-05 01:39:02 -07:00
401a63f544 bench: add profiling workload generator
Generates Forth-style command scripts that build a multi-dimensional
model and exercise the grid aggregation hot path via repeated
export-csv calls. Used for profiling with samply.

Usage:
  python3 bench/gen_workload.py --scale 5 > /tmp/workload.txt
  cargo build --profile profiling
  samply record ./target/profiling/improvise script /tmp/workload.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:38:45 -07:00
3d11daca18 chore: update gitignore 2026-04-05 01:09:37 -07:00
ab5f3a5a86 feat(build): add profiling profile configuration
Add a new [profile.profiling] section to Cargo.toml.

This profile inherits from release but with:
- strip = false: Keep debug symbols for profiling
- debug = 2: Full debug information for analysis

Useful for generating profiling data with symbol information.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 01:09:17 -07:00
377d417e5a feat(model): add symbol table module
Add a new SymbolTable module for interned string identifiers.

The module implements a bidirectional mapping between strings and
Symbol IDs using a HashMap. Key functionality includes:

- intern(): Add a string to the table and return its Symbol ID
- get(): Look up a string by Symbol ID
- resolve(): Get the original string for a Symbol ID
- intern_pair() and intern_coords(): Helper functions for structured
  data interning

The implementation includes unit tests to verify correct behavior.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 01:09:17 -07:00
872c4c6c5d refactor: add Default derives to CmdRegistry and Keymap
Add #[derive(Default)] to CmdRegistry and Keymap structs, enabling
easy construction of empty instances with CmdRegistry::default() and
Keymap::default().

This simplifies initialization code and follows Rust conventions for
types that hold empty collections as their default state.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 01:07:08 -07:00
d3a1a57c78 refactor: improve dot separator parsing in command parser
Change split_on_dot() to require dot to be a standalone word
surrounded by whitespace or at line boundaries, rather than any
dot character.

This prevents accidental splitting on dots within identifiers or
quoted strings, making the command syntax more predictable.

The new logic checks both preceding and following bytes to ensure
the dot is truly isolated before treating it as a separator.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 01:07:08 -07:00
82ad459c4e feat: intern cell keys for O(1) comparison and indexing
Refactor DataStore to use interned keys (InternedKey) instead of
string-based CellKey for O(1) hash and compare operations.

Introduce SymbolTable-backed interning for all category and item
names, storing them as Symbol identifiers throughout the data structure.

Add secondary index mapping (category, item) pairs to sets of interned
keys, enabling efficient partial match queries without scanning all cells.

Optimize matching_values() to avoid allocating CellKey strings by
working directly with interned keys and intersecting index sets.

Update all callers to use new API: iter_cells(), matching_values(),
and internal lookup_key() helper.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-05 01:07:08 -07:00
6c211e5421 feat(ui): add buffers HashMap for text input state management
Introduce a buffers HashMap to manage text input state across different
modes (command, edit, formula, category, export).

Changes:
- Added buffers field to GridWidget and updated constructor
- Updated draw_command_bar to use app.buffers instead of mode buffer
- Updated grid edit indicator to use buffers HashMap
- Added tests for command mode buffer behavior:
  * command_mode_typing_appends_to_buffer
  * command_mode_buffer_cleared_on_reentry

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 18:37:43 -07:00
0f1de6ba58 refactor(keymap): add Char key fallback and remove unused SHIFT binding
Improve keymap lookup for Char keys by adding fallback to NONE modifiers.
Terminals vary in whether they send SHIFT for uppercase/symbol characters,
so we now retry without modifiers when an exact match fails.

Also removed the unused shift variable and updated key bindings to use
NONE modifiers instead of SHIFT for consistency.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 18:37:43 -07:00
89fdb27d6c refactor(command): switch to prototype-based command registration
Refactor command registry to use prototype-based registration instead of
string-based names. This makes the API more consistent and type-safe.

Changes:
- Changed Cmd::name() to return &'static str instead of &str
- Updated CmdRegistry::register, register_pure, and register_nullary to accept
  prototype command instances instead of string names
- Added NamedCmd helper struct for cases where command is built by closure
- Updated all command implementations to return static string literals
- Modified EnterMode::execute to clear buffers when entering text-entry modes

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 18:37:43 -07:00
e2ff9cf98e refactor(command): remove key_modifiers from CmdContext
Remove the key_modifiers field from CmdContext struct and all its usages.

This simplifies the command context by removing unused modifier state.
The Cmd trait's execute method no longer receives key modifiers.

Changes:
- Removed KeyModifiers import from cmd.rs
- Removed key_modifiers field from CmdContext struct
- Removed file_path_set field from CmdContext (unused)
- Updated App::cmd_context to not populate key_modifiers
- Removed KeymapSet::registry() accessor
- Updated test code to match new struct layout
- Added documentation to Cmd::name() method

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 12:40:55 -07:00
c6c8ac2c69 chore: clippy + format 2026-04-04 12:34:50 -07:00
35946afc91 refactor(command): pre-resolve cell key and grid dimensions in CmdContext
Refactor command system to pre-resolve cell key and grid dimensions
in CmdContext, eliminating repeated GridLayout construction.

Key changes:
- Add cell_key, row_count, col_count to CmdContext
- Replace generic CmdRegistry::register with
  register/register_pure/register_nullary
- Cell commands (clear-cell, yank, paste) now take explicit CellKey
- Update keymap dispatch to use new interactive() method
- Special-case "search" buffer in SetBuffer effect
- Update tests to populate new context fields

This reduces code duplication and makes command execution more
efficient by computing layout once at context creation time.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 12:33:11 -07:00
649d80cb35 refactor(command): decouple keymap bindings from command implementations
Refactor the keymap system to use string-based command names instead of
concrete command struct instantiations. This introduces a Binding enum that
can represent either a command lookup (name + args) or a prefix sub-keymap.

Key changes:
- Keymap now stores Binding enum instead of Arc<dyn Cmd>
- dispatch() accepts CmdRegistry to resolve commands at runtime
- Added bind_args() for commands with arguments
- KeymapSet now owns the command registry
- Removed PrefixKey struct, inlined its logic
- Updated all default keymap bindings to use string names

This enables more flexible command configuration and easier testing.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 11:02:00 -07:00
a45390b7a9 refactor(command): rename commands and extract panel/axis parsers
Rename several commands for consistency:
- save -> save-as (SaveAsCmd)
- save-cmd -> save (SaveCmd)
- enter-search -> search (EnterSearchMode)
- enter-edit -> enter-edit-mode (EnterEditMode)
- exit-search -> exit-search-mode (ExitSearchMode)

Add new commands:
- search-or-category-add
- search-append-char
- search-pop-char
- toggle-panel-and-focus
- toggle-panel-visibility
- command-mode-backspace

Extract parse_panel() and parse_axis() helper functions to replace
repeated match statements. Update documentation comments to reflect
quasi-lisp syntax instead of Forth-style.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:57:37 -07:00
830869d91c test(command): update tests to use ExecuteCommand instead of QuitCmd
Update command tests to work with the new trait-based system:

- Tests now use ExecuteCommand instead of QuitCmd
- Added buffer setup with 'q' command for quit functionality
- Tests verify effects contain SetStatus or ChangeMode via debug output
- Removed direct QuitCmd construction in favor of ExecuteCommand

The tests verify that quit behavior works correctly when dirty vs
clean, ensuring the new command system produces the expected effects.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:35 -07:00
32716ebc16 refactor(main): update headless mode to use App and new command system
Update headless command execution to use the new architecture:

- Removed CommandResult import (no longer needed)
- Headless mode now creates an App instance and uses cmd_context()
- Commands are parsed and executed via the registry, effects applied
  through app.apply_effects() instead of command::dispatch()
- Made cmd_context() public so headless mode can access it
- Updated persistence save to use app.model instead of direct model

Tests updated to use ExecuteCommand instead of QuitCmd, with proper
buffer setup for command parsing.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:35 -07:00
00c62d85b7 feat(ui): add LoadModel and ImportJsonHeadless effects
Add two new effects for headless model operations:

- LoadModel: Loads a model from a file path, replacing the current one
- ImportJsonHeadless: Imports JSON/CSV files via the analyzer, builds
  a new model from detected fields, and replaces the current model

These effects enable headless mode to load and import data without
interactive prompts. ImportJsonHeadless handles both CSV and JSON
files, auto-detects array paths, and uses the existing import pipeline.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:35 -07:00
64ab352490 refactor(command): update parsing to use registry-based system
Update the command parsing layer to use the new CmdRegistry:

- parse_line() now uses default_registry() and returns Vec<Box<dyn Cmd>>
- parse_line_with() accepts a registry parameter for custom registries
- Tokenization replaced direct Command construction with registry.parse()
- Updated tests to verify command names instead of struct fields
- Removed parse_command() and helper functions (require_args, parse_coords,
  etc.)

The parser now delegates command construction to the registry, which
allows commands to be defined and registered in one place.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:35 -07:00
909c20bcbd refactor(command): remove old Command enum and dispatch system
Remove the old JSON-based command infrastructure:

- Delete Command enum and CommandResult from types.rs
- Remove QuitCmd and InitBuffer command implementations
- Delete entire dispatch.rs file that handled command execution
- Remove Command type exports from mod.rs

The old system used a monolithic Command enum with serde serialization.
The new trait-based system is more flexible and doesn't require JSON
serialization for command execution.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:35 -07:00
1e8bc7a135 feat(command): add trait-based command system with registry
Introduce a new trait-based command architecture that replaces the
previous JSON-based Command enum. The new system uses:

- Cmd trait: Commands are trait objects that produce Effects
- CmdRegistry: Central registry for parsing commands from text
- ParseFn: Function type for parsing string arguments into commands
- effect_cmd! macro: Helper macro for defining parseable commands

The registry allows commands to be registered by name and parsed from
Forth-style text arguments. This enables both TUI and headless modes
to use the same command parsing infrastructure.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:56:34 -07:00
2be1eeae5d refactor(app): remove legacy command execution code
Remove unused imports for legacy code that is no longer needed.

Delete execute_command function that handled :q, :w, :import
commands via direct AppMode matching.

Delete handle_wizard_key function and associated fallback logic
for modes not yet migrated to keymaps. These are now handled
by the new keymap-based approach.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:42:25 -07:00
b8cff2488c feat(effect): add WizardKey and StartImportWizard effects
Add WizardKey effect to handle key bindings for navigating
wizard steps: Preview, SelectArrayPath, ReviewProposals,
ConfigureDates, DefineFormulas, and NameModel.

Add StartImportWizard effect to initialize the wizard by
reading and parsing a JSON file.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:42:25 -07:00
4941b6f44c feat(keymap): add ImportWizard mode with Any key pattern
Add Any key pattern as lowest priority fallback in KeyPattern enum.

Add ImportWizard to ModeKey enum and its mapping from AppMode.
Modify key lookup to fall back to Any pattern for unmatched keys.

Change Enter key in command mode to execute ExecuteCommand.
Add ImportWizard keymap that binds all keys to HandleWizardKey.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:42:25 -07:00
630367a9b0 feat(command): add HandleWizardKey and ExecuteCommand handlers
Introduce HandleWizardKey command to dispatch keys to the import wizard.

Add ExecuteCommand implementation that parses and executes various
commands like :quit, :write, :import, :add-item, and :formula.
Handles argument parsing, validation, and mode transitions.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 10:42:25 -07:00
3c561adf05 chore: clippy send + sync warnings, drop warnings 2026-04-04 10:01:27 -07:00
0db89b1e3a chore: clippy + fmt 2026-04-04 09:59:01 -07:00
d8f7d9a501 feat(commands): add panel cursor and tile selection commands
Add comprehensive command implementations for managing panel cursors
(formula_cursor, cat_panel_cursor, view_panel_cursor), tile selection,
text buffers, and search functionality.

Update EnterEditMode to use SetBuffer effect before changing mode.
Update EnterTileSelect to use SetTileCatIdx effect before changing mode.

Add keymap bindings for all new modes with navigation (arrows/hjkl),
editing actions (Enter, Backspace, Char), and mode transitions (Esc, Tab).

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 09:58:31 -07:00
ebe8df89ee refactor(app): wire up keymap dispatch and remove old handlers
Update handle_key to pass key code and modifiers to cmd_context.
Update keymap_set.dispatch to pass search_mode from App state.

Remove old-style panel handlers (handle_edit_key, handle_formula_edit_key,
handle_formula_panel_key, etc.) - approximately 500 lines.

Update handle_command_mode_key to use buffers map for command execution.
All other modes now handled via keymap dispatch.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 09:58:31 -07:00
5cd3cf3c18 feat(app): add tile_cat_idx and buffers to App state
Add tile_cat_idx field to track selected tile category index.
Add buffers HashMap for named text buffers used in text-entry modes.

Update AppMode::TileSelect to remove nested cat_idx struct.
Update cmd_context to accept key and modifiers parameters.
Update cmd_context to populate new fields from App state.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 09:58:31 -07:00
e976b3c49a feat(keymap): add AnyChar pattern and new mode variants
Add AnyChar key pattern for text-entry modes that matches any Char key.

Add new mode variants to ModeKey: FormulaPanel, CategoryPanel, ViewPanel,
TileSelect, Editing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt,
CommandMode, and SearchMode.

Update Keymap::lookup to fall back to AnyChar for Char keys.
Update KeymapSet::get to accept search_mode parameter.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 09:58:31 -07:00
b7e4316cef feat(command): add CmdContext extensions and new effects
Add new fields to CmdContext for tracking search mode, panel cursors,
tile category index, named text buffers, and key information.

Add SetBuffer and SetTileCatIdx effects for managing application state.
Update TileBar to accept tile_cat_idx parameter for rendering.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
2026-04-04 09:58:30 -07:00
56839b81d2 fix transient keymap consumption bug
- Fixed transient keymap consumption logic: the transient keymap is now only
  consumed when a match is found.
- Updated handle_key to retain transient keymap if no command matches, allowing
  subsequent key presses to be processed by the main keymap set.
- Added issue noting the previous unintended consumption.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
67fca18200 tidy method calls
- Reformatted method calls in RemoveFormula and SetAxis for consistency.
- Minor formatting changes to improve readability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
387190c9f7 overhaul keymap handling and remove pending key
- Updated imports to include Arc and removed KeyModifiers.
- Replaced pending_key with transient_keymap and keymap_set.
- Added KeymapSet for mode-specific keymaps.
- Removed legacy pending key logic and many helper methods.
- Updated tests to use new command execution pattern.
- Adjusted App struct and methods to align with new keymap system.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
dfae4a882d tidy apply_config_to_pipeline signature
- Combined function signature into a single line for readability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
9afa13f78a improve error formatting
- Added missing comma in error message for set-cell command.
- Reformatted error messages for consistency.
- Minor formatting changes to improve readability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
bfc30cb7b2 overhaul keymap API and add Debug
- Replaced ModeKey with direct KeyPattern keys.
- Stored bindings as Arc<dyn Cmd> for cheap sharing.
- Added Debug implementation for Keymap.
- Updated bind, bind_cmd, bind_prefix, lookup, and dispatch signatures.
- Introduced PrefixKey command and SetTransientKeymap effect.
- Added KeymapSet for mode-specific keymaps.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:49 -07:00
c188ce3f9d add panel toggling and new command implementations
- Implemented a suite of new commands for panel visibility, editing, export
  prompts, search navigation, page cycling, and grid operations.
- Updated tests to cover new command behavior.
- Adjusted command context usage accordingly.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:48 -07:00
f2bb8ec2a7 update CmdContext and imports
- Updated imports to include Panel and Axis.
- Added new fields to CmdContext: formula_panel_open, category_panel_open,
  view_panel_open.
- Reformatted effect vectors for consistency.
- Minor formatting changes to improve readability.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (gpt-oss:20b)
2026-04-04 09:31:48 -07:00
038c99c473 chore: update gitignore 2026-04-03 23:07:13 -07:00
f7436e73ba refactor: add Keymap with default bindings and wire into handle_key
Create keymap.rs with Keymap struct mapping (mode, key) to Cmd trait
objects. Wire into App::handle_key — keymap dispatch is tried first,
falling through to old handlers for unmigrated bindings. Normal mode
navigation, cell ops, mode switches, and Help mode are keymap-driven.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:40:36 -07:00
0c751b7b8b refactor: add Cmd trait with CmdContext and first command implementations
Define Cmd trait (execute returns Vec<Box<dyn Effect>>) and CmdContext
(read-only state snapshot). Implement navigation commands (MoveSelection,
JumpTo*, ScrollRows), mode commands (EnterMode, Quit, SaveAndQuit),
cell operations (ClearSelectedCell, YankCell, PasteCell), and view
commands (TransposeAxes, Save, EnterSearchMode).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:38:56 -07:00
9421d01da5 refactor: add Effect trait and apply_effects infrastructure
Define Effect trait in ui/effect.rs with concrete effect structs for
all model mutations, view changes, navigation, and app state updates.
Each effect implements apply(&self, &mut App). Add App::apply_effects
to apply a sequence of effects. No behavior change yet — existing
key handlers still work as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:36:44 -07:00
567ca341f7 feat: Forth-style prefix command parser
Replace JSON command syntax with prefix notation: `word arg1 arg2`.
Multiple commands per line separated by `.`. Coordinate pairs use
`Category/Item`. Quoted strings for multi-word values. set-cell
uses value-first: `set-cell 100 Region/East Measure/Revenue`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:14:37 -07:00
6647be30fa refactor: switch to clap with subcommands for CLI parsing
Replace hand-rolled arg parser with clap derive. Restructure as
subcommands: import, cmd, script. Import subcommand supports
--category, --measure, --time, --skip, --extract, --axis, --formula,
--name, --no-wizard, and --output flags for configurable imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:43:16 -07:00
ac0c538c98 feat: adjust arg processing so script+command modes are exclusive 2026-04-03 20:32:55 -07:00
9f5b7f602a chore: cleanup flake 2026-04-03 13:44:26 -07:00
4525753109 chore(merge): remote-tracking branch 'origin/main' 2026-04-03 13:42:07 -07:00
4233d3fbf4 feat: wizard UI for date config and formula steps
Add key handling for ConfigureDates (space toggle components) and
DefineFormulas (n new, d delete, text input mode) wizard steps.
Render date component toggles, formula list with input area, and
sample formulas derived from detected measures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:41:18 -07:00
a73fe160c7 feat: date parsing, component extraction, and wizard formulas
Extend FieldProposal with chrono-based date format detection and
configurable component extraction (Year, Month, Quarter). Add
ConfigureDates and DefineFormulas wizard steps to ImportPipeline.
build_model injects derived date categories and parses formula strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:41:05 -07:00
5a251a1cbe feat: add Axis::None for hidden dimensions with implicit aggregation
Categories on the None axis are excluded from the grid and cell keys.
When evaluating cells, values across hidden dimensions are aggregated
using a per-measure function (default SUM). Adds evaluate_aggregated
to Model, none_cats to GridLayout, and 'n' shortcut in TileSelect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:38:35 -07:00
dd728ccac8 feat: multiple-CSV import 2026-04-02 16:21:45 -07:00
77b33b7a85 refactor: further cleanup of linux build 2026-04-02 15:58:28 -07:00
e831648b18 feat(build): don't bother with static build 2026-04-02 15:47:22 -07:00
be277f43c2 feat(build): use crate2nix 2026-04-02 11:34:22 -07:00
edd6431444 refactor: use data_col_to_visual via group_for helpers, add column group toggle
Add row_group_for/col_group_for to GridLayout, replacing inline
backward-search logic. Refactor grid renderer to use col_group_for
instead of pre-filtering col_items. Add gz keybinding for column
group collapse toggle, symmetric with z for rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:22:00 -07:00
5136aadd86 fix: remove tests for top_level_groups 2026-04-02 10:18:32 -07:00
b9da06c55f fix: csv_parser tests 2026-04-02 10:18:01 -07:00
edd33d6dee chore: cleanup dead code in category.rs 2026-04-02 10:07:49 -07:00
2c9d9c7de7 chore: move csv_path_p, restructure modules 2026-04-02 10:01:51 -07:00
368b303eac chore(merge): remote-tracking branch 'origin/main' 2026-04-02 09:35:41 -07:00
fe74cc5fcb chore: clippy + fmt 2026-04-02 09:35:02 -07:00
b9818204a4 chore: clippy + fmt 2026-04-01 22:17:11 -07:00
1d5edd2c09 fix: handle PathBuf correctly 2026-04-01 22:16:56 -07:00
da93145de5 refactor: introduce draw module 2026-04-01 08:54:22 -07:00
fcfdc09732 chore: cargo fmt 2026-04-01 01:37:40 -07:00
23e26f0e06 Add CSV import functionality
- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support

All 201 tests pass.
2026-04-01 01:37:04 -07:00
2cf1123bcb refactor: cleanup main.rs 2026-04-01 01:35:43 -07:00
37 changed files with 7782 additions and 2645 deletions

1
.envrc
View File

@ -1 +1,2 @@
use flake
unset TMPDIR

5
.gitignore vendored
View File

@ -3,3 +3,8 @@ target/
.DS_Store
/result
.direnv
[#]*
symbols.json
profile.json
profile.json.gz
bench/*.txt

138
Cargo.lock generated
View File

@ -23,6 +23,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@ -107,6 +157,52 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
@ -161,6 +257,27 @@ dependencies = [
"winapi",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -373,7 +490,9 @@ version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"crossterm",
"csv",
"dirs",
"flate2",
"indexmap",
@ -381,6 +500,7 @@ dependencies = [
"ratatui",
"serde",
"serde_json",
"tempfile",
"thiserror",
"unicode-width 0.2.0",
]
@ -419,6 +539,12 @@ dependencies = [
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@ -544,6 +670,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -1017,6 +1149,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wait-timeout"
version = "0.2.1"

View File

@ -21,12 +21,20 @@ chrono = { version = "0.4", features = ["serde"] }
flate2 = "1"
unicode-width = "0.2"
dirs = "5"
csv = "1"
clap = { version = "4.6.0", features = ["derive"] }
[dev-dependencies]
proptest = "1"
tempfile = "3"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
[profile.profiling]
inherits = "release"
strip = false
debug = 2

83
bench/gen_workload.py Normal file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Generate a profiling workload script for improvise.
Usage:
python3 bench/gen_workload.py [--scale N] > bench/large_workload.txt
cargo build --release
time ./target/release/improvise script bench/large_workload.txt
For flamegraph profiling:
samply record ./target/release/improvise script bench/large_workload.txt
"""
import argparse
import random
parser = argparse.ArgumentParser()
parser.add_argument("--scale", type=int, default=1,
help="Scale factor (1=small, 5=medium, 10=large)")
parser.add_argument("--density", type=float, default=0.3,
help="Cell density (0.0-1.0)")
parser.add_argument("--exports", type=int, default=0,
help="Number of export passes (0 = one per month)")
args = parser.parse_args()
random.seed(42)
S = args.scale
n_regions = 5 * S
n_products = 8 * S
n_months = 12
n_channels = 4 + S
measures = ["Revenue", "Cost", "Units"]
regions = [f"R{i:03d}" for i in range(n_regions)]
products = [f"P{i:03d}" for i in range(n_products)]
months = [f"M{i:02d}" for i in range(1, n_months + 1)]
channels = [f"Ch{i:02d}" for i in range(n_channels)]
potential = n_regions * n_products * n_months * n_channels * len(measures)
print(f"# Scale={S}, Density={args.density}")
print(f"# {n_regions} regions × {n_products} products × {n_months} months × {n_channels} channels × {len(measures)} measures")
print(f"# Potential cells: {potential}, Expected: ~{int(potential * args.density)}")
print()
for cat in ["Region", "Product", "Month", "Channel", "Measure"]:
print(f"add-category {cat}")
for items, cat in [(regions, "Region"), (products, "Product"),
(months, "Month"), (channels, "Channel"),
(measures, "Measure")]:
for item in items:
print(f"add-item {cat} {item}")
print("set-axis Region row")
print("set-axis Product column")
print("set-axis Month page")
print("set-axis Channel none")
print("set-axis Measure none")
n = 0
for r in regions:
for p in products:
for m in months:
for c in channels:
if random.random() < args.density:
rev = random.randint(100, 10000)
cost = random.randint(50, rev)
units = random.randint(1, 500)
print(f"set-cell {rev} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Revenue")
print(f"set-cell {cost} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Cost")
print(f"set-cell {units} Region/{r} Product/{p} Month/{m} Channel/{c} Measure/Units")
n += 3
print(f"# Total cells: {n}")
print('add-formula Measure "Profit = Revenue - Cost"')
print('add-formula Measure "Margin = Profit / Revenue"')
print('add-formula Measure "AvgPrice = Revenue / Units"')
n_exports = args.exports if args.exports > 0 else n_months
for i, m in enumerate(months[:n_exports]):
print(f"set-page Month {m} . export-csv /tmp/improvise_bench_{i:02d}.csv")
print("# Done")

260
flake.lock generated
View File

@ -1,5 +1,111 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"crate2nix"
],
"flake-compat": [
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix",
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": "nixpkgs_2",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1774369503,
"narHash": "sha256-YeCF4iBhlvTqkn4mihjZgixnDcEVgfyQlNeBsbLYUgQ=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "b873ca53dd64e12340416f0fd5e3b33792b9c17b",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -18,7 +124,128 @@
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"crate2nix",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"crate2nix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1769433173,
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1774709303,
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
@ -34,7 +261,7 @@
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs_4": {
"locked": {
"lastModified": 1774794121,
"narHash": "sha256-gih24b728CK8twDNU7VX9vVYK2tLEXvy9gm/GKq2VeE=",
@ -50,16 +277,43 @@
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_2",
"nixpkgs": [
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"crate2nix": "crate2nix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1774926780,

View File

@ -7,6 +7,7 @@
url = "github:oxalica/rust-overlay";
};
flake-utils.url = "github:numtide/flake-utils";
crate2nix.url = "github:nix-community/crate2nix";
};
outputs = {
@ -14,65 +15,37 @@
nixpkgs,
rust-overlay,
flake-utils,
crate2nix,
}:
flake-utils.lib.eachDefaultSystem (system: let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;};
isLinux = pkgs.lib.hasInfix "linux" system;
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "clippy" "rustfmt"];
targets = pkgs.lib.optionals isLinux ["x86_64-unknown-linux-musl"];
};
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
name = "improvise";
src = ./.;
};
cargoNix = import generatedCargoNix {
pkgs = pkgs;
};
in {
devShells.default = pkgs.mkShell ({
nativeBuildInputs =
[
rustToolchain
pkgs.pkg-config
pkgs.rust-analyzer
]
++ pkgs.lib.optionals isLinux [
# Provide cc (gcc) for building proc-macro / build-script crates
# that target the host (x86_64-unknown-linux-gnu).
pkgs.gcc
# musl-gcc wrapper for the static musl target.
pkgs.pkgsMusl.stdenv.cc
];
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
rustToolchain
pkgs.pkg-config
pkgs.rust-analyzer
crate2nix.packages.${system}.default
];
RUST_BACKTRACE = "1";
};
RUST_BACKTRACE = "1";
}
// pkgs.lib.optionalAttrs isLinux {
# Tell Cargo which linker to use for each target so it never
# falls back to rust-lld (which can't find glibc on NixOS).
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER = "${pkgs.gcc}/bin/gcc";
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER = "${pkgs.pkgsMusl.stdenv.cc}/bin/cc";
# Default build target: static musl binary.
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
});
packages.default =
if isLinux
then
(pkgs.pkgsMusl.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
}).buildRustPackage {
pname = "improvise";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
}
else
(pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
}).buildRustPackage {
pname = "improvise";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
};
packages.default = cargoNix.rootCrate.build;
});
}

2907
src/command/cmd.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,230 +0,0 @@
use super::types::{CellValueArg, Command, CommandResult};
use crate::formula::parse_formula;
use crate::import::analyzer::{analyze_records, extract_array_at_path, FieldKind};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::persistence;
/// Execute a command against the model, returning a result.
/// This is the single authoritative mutation path used by both the TUI and headless modes.
pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
match cmd {
Command::AddCategory { name } => match model.add_category(name) {
Ok(_) => CommandResult::ok_msg(format!("Category '{name}' added")),
Err(e) => CommandResult::err(e.to_string()),
},
Command::AddItem { category, item } => match model.category_mut(category) {
Some(cat) => {
cat.add_item(item);
CommandResult::ok()
}
None => CommandResult::err(format!("Category '{category}' not found")),
},
Command::AddItemInGroup {
category,
item,
group,
} => match model.category_mut(category) {
Some(cat) => {
cat.add_item_in_group(item, group);
CommandResult::ok()
}
None => CommandResult::err(format!("Category '{category}' not found")),
},
Command::SetCell { coords, value } => {
let kv: Vec<(String, String)> = coords
.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
// Validate all categories exist before mutating anything
for (cat_name, _) in &kv {
if model.category(cat_name).is_none() {
return CommandResult::err(format!("Category '{cat_name}' not found"));
}
}
// Ensure items exist within their categories
for (cat_name, item_name) in &kv {
model.category_mut(cat_name).unwrap().add_item(item_name);
}
let key = CellKey::new(kv);
let cell_value = match value {
CellValueArg::Number { number } => CellValue::Number(*number),
CellValueArg::Text { text } => CellValue::Text(text.clone()),
};
model.set_cell(key, cell_value);
CommandResult::ok()
}
Command::ClearCell { coords } => {
let kv: Vec<(String, String)> = coords
.iter()
.map(|pair| (pair[0].clone(), pair[1].clone()))
.collect();
let key = CellKey::new(kv);
model.clear_cell(&key);
CommandResult::ok()
}
Command::AddFormula {
raw,
target_category,
} => {
match parse_formula(raw, target_category) {
Ok(formula) => {
// Ensure the target item exists in the target category
let target = formula.target.clone();
let cat_name = formula.target_category.clone();
if let Some(cat) = model.category_mut(&cat_name) {
cat.add_item(&target);
}
model.add_formula(formula);
CommandResult::ok_msg(format!("Formula '{raw}' added"))
}
Err(e) => CommandResult::err(format!("Parse error: {e}")),
}
}
Command::RemoveFormula {
target,
target_category,
} => {
model.remove_formula(target, target_category);
CommandResult::ok()
}
Command::CreateView { name } => {
model.create_view(name);
CommandResult::ok()
}
Command::DeleteView { name } => match model.delete_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
},
Command::SwitchView { name } => match model.switch_view(name) {
Ok(_) => CommandResult::ok(),
Err(e) => CommandResult::err(e.to_string()),
},
Command::SetAxis { category, axis } => {
model.active_view_mut().set_axis(category, *axis);
CommandResult::ok()
}
Command::SetPageSelection { category, item } => {
model.active_view_mut().set_page_selection(category, item);
CommandResult::ok()
}
Command::ToggleGroup { category, group } => {
model
.active_view_mut()
.toggle_group_collapse(category, group);
CommandResult::ok()
}
Command::HideItem { category, item } => {
model.active_view_mut().hide_item(category, item);
CommandResult::ok()
}
Command::ShowItem { category, item } => {
model.active_view_mut().show_item(category, item);
CommandResult::ok()
}
Command::Save { path } => match persistence::save(model, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Saved to {path}")),
Err(e) => CommandResult::err(e.to_string()),
},
Command::Load { path } => match persistence::load(std::path::Path::new(path)) {
Ok(mut loaded) => {
loaded.normalize_view_state();
*model = loaded;
CommandResult::ok_msg(format!("Loaded from {path}"))
}
Err(e) => CommandResult::err(e.to_string()),
},
Command::ExportCsv { path } => {
let view_name = model.active_view.clone();
match persistence::export_csv(model, &view_name, std::path::Path::new(path)) {
Ok(_) => CommandResult::ok_msg(format!("Exported to {path}")),
Err(e) => CommandResult::err(e.to_string()),
}
}
Command::ImportJson {
path,
model_name,
array_path,
} => import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
}
}
fn import_json_headless(
model: &mut Model,
path: &str,
model_name: Option<&str>,
array_path: Option<&str>,
) -> CommandResult {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return CommandResult::err(format!("Cannot read '{path}': {e}")),
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => return CommandResult::err(format!("JSON parse error: {e}")),
};
let records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => return CommandResult::err(format!("No array at path '{ap}'")),
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
// Find first array
let paths = crate::import::analyzer::find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => return CommandResult::err("Could not extract records array"),
}
} else {
return CommandResult::err("No array found in JSON");
}
};
let proposals = analyze_records(&records);
// Auto-accept all and build via ImportPipeline
let pipeline = crate::import::wizard::ImportPipeline {
raw: value,
array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(),
records,
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: model_name.unwrap_or("Imported Model").to_string(),
};
match pipeline.build_model() {
Ok(new_model) => {
*model = new_model;
CommandResult::ok_msg("JSON imported successfully")
}
Err(e) => CommandResult::err(e.to_string()),
}
}

612
src/command/keymap.rs Normal file
View File

@ -0,0 +1,612 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyModifiers};
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use super::cmd::{self, CmdContext, CmdRegistry};
// `cmd` module imported for `default_registry()` in default_keymaps()
/// A key pattern that can be matched against a KeyEvent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyPattern {
/// Single key with modifiers
Key(KeyCode, KeyModifiers),
/// Matches any Char key (for text-entry modes).
AnyChar,
/// Matches any key at all (lowest priority fallback).
Any,
}
/// Identifies which mode a binding applies to.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModeKey {
Normal,
Help,
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
Editing,
FormulaEdit,
CategoryAdd,
ItemAdd,
ExportPrompt,
CommandMode,
SearchMode,
ImportWizard,
}
impl ModeKey {
pub fn from_app_mode(mode: &AppMode, search_mode: bool) -> Option<Self> {
match mode {
AppMode::Normal if search_mode => Some(ModeKey::SearchMode),
AppMode::Normal => Some(ModeKey::Normal),
AppMode::Help => Some(ModeKey::Help),
AppMode::FormulaPanel => Some(ModeKey::FormulaPanel),
AppMode::CategoryPanel => Some(ModeKey::CategoryPanel),
AppMode::ViewPanel => Some(ModeKey::ViewPanel),
AppMode::TileSelect => Some(ModeKey::TileSelect),
AppMode::Editing { .. } => Some(ModeKey::Editing),
AppMode::FormulaEdit { .. } => Some(ModeKey::FormulaEdit),
AppMode::CategoryAdd { .. } => Some(ModeKey::CategoryAdd),
AppMode::ItemAdd { .. } => Some(ModeKey::ItemAdd),
AppMode::ExportPrompt { .. } => Some(ModeKey::ExportPrompt),
AppMode::CommandMode { .. } => Some(ModeKey::CommandMode),
AppMode::ImportWizard => Some(ModeKey::ImportWizard),
_ => None,
}
}
}
/// What a key binding resolves to.
#[derive(Debug, Clone)]
pub enum Binding {
/// A command name + arguments, looked up in the registry at dispatch time.
Cmd {
name: &'static str,
args: Vec<String>,
},
/// A prefix sub-keymap (Emacs-style).
Prefix(Arc<Keymap>),
}
/// A keymap maps key patterns to bindings (command names or prefix sub-keymaps).
#[derive(Default)]
pub struct Keymap {
bindings: HashMap<KeyPattern, Binding>,
}
impl fmt::Debug for Keymap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Keymap")
.field("binding_count", &self.bindings.len())
.finish()
}
}
impl Keymap {
pub fn new() -> Self {
Self {
bindings: HashMap::new(),
}
}
/// Bind a key to a command name (no args).
pub fn bind(&mut self, key: KeyCode, mods: KeyModifiers, name: &'static str) {
self.bindings.insert(
KeyPattern::Key(key, mods),
Binding::Cmd { name, args: vec![] },
);
}
/// Bind a key to a command name with arguments.
pub fn bind_args(
&mut self,
key: KeyCode,
mods: KeyModifiers,
name: &'static str,
args: Vec<String>,
) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Cmd { name, args });
}
/// Bind a prefix key that activates a sub-keymap.
pub fn bind_prefix(&mut self, key: KeyCode, mods: KeyModifiers, sub: Arc<Keymap>) {
self.bindings
.insert(KeyPattern::Key(key, mods), Binding::Prefix(sub));
}
/// Bind a catch-all for any Char key.
pub fn bind_any_char(&mut self, name: &'static str, args: Vec<String>) {
self.bindings
.insert(KeyPattern::AnyChar, Binding::Cmd { name, args });
}
/// Bind a catch-all for any key at all.
pub fn bind_any(&mut self, name: &'static str) {
self.bindings
.insert(KeyPattern::Any, Binding::Cmd { name, args: vec![] });
}
/// Look up the binding for a key.
/// For Char keys, if exact (key, mods) match fails, retries with NONE
/// modifiers since terminals vary in whether they send SHIFT for
/// uppercase/symbol characters.
pub fn lookup(&self, key: KeyCode, mods: KeyModifiers) -> Option<&Binding> {
self.bindings
.get(&KeyPattern::Key(key, mods))
.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))
} else {
None
}
})
.or_else(|| {
if matches!(key, KeyCode::Char(_)) {
self.bindings.get(&KeyPattern::AnyChar)
} else {
None
}
})
.or_else(|| self.bindings.get(&KeyPattern::Any))
}
/// Dispatch a key: look up binding, resolve through registry, return effects.
pub fn dispatch(
&self,
registry: &CmdRegistry,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let binding = self.lookup(key, mods)?;
match binding {
Binding::Cmd { name, args } => {
let cmd = registry.interactive(name, args, ctx).ok()?;
Some(cmd.execute(ctx))
}
Binding::Prefix(sub) => Some(vec![Box::new(SetTransientKeymap(sub.clone()))]),
}
}
}
/// Effect that sets the transient keymap on the App.
#[derive(Debug)]
pub struct SetTransientKeymap(pub Arc<Keymap>);
impl Effect for SetTransientKeymap {
fn apply(&self, app: &mut crate::ui::app::App) {
app.transient_keymap = Some(self.0.clone());
}
}
/// Maps modes to their root keymaps + owns the command registry.
pub struct KeymapSet {
mode_maps: HashMap<ModeKey, Arc<Keymap>>,
registry: CmdRegistry,
}
impl KeymapSet {
pub fn new(registry: CmdRegistry) -> Self {
Self {
mode_maps: HashMap::new(),
registry,
}
}
pub fn insert(&mut self, mode: ModeKey, keymap: Arc<Keymap>) {
self.mode_maps.insert(mode, keymap);
}
/// Dispatch a key event: returns effects if a binding matched.
pub fn dispatch(
&self,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
let mode_key = ModeKey::from_app_mode(ctx.mode, ctx.search_mode)?;
let keymap = self.mode_maps.get(&mode_key)?;
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Dispatch against a specific keymap (for transient/prefix keymaps).
pub fn dispatch_transient(
&self,
keymap: &Keymap,
ctx: &CmdContext,
key: KeyCode,
mods: KeyModifiers,
) -> Option<Vec<Box<dyn Effect>>> {
keymap.dispatch(&self.registry, ctx, key, mods)
}
/// Build the default keymap set with all bindings.
pub fn default_keymaps() -> Self {
let registry = cmd::default_registry();
let mut set = Self::new(registry);
let none = KeyModifiers::NONE;
let ctrl = KeyModifiers::CONTROL;
// ── Normal mode ──────────────────────────────────────────────────
let mut normal = Keymap::new();
// Navigation
for (key, dr, dc) in [
(KeyCode::Up, -1, 0),
(KeyCode::Down, 1, 0),
(KeyCode::Left, 0, -1),
(KeyCode::Right, 0, 1),
] {
normal.bind_args(
key,
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
for (ch, dr, dc) in [('k', -1, 0), ('j', 1, 0), ('h', 0, -1), ('l', 0, 1)] {
normal.bind_args(
KeyCode::Char(ch),
none,
"move-selection",
vec![dr.to_string(), dc.to_string()],
);
}
// Jump to boundaries
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");
// 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()]);
// Cell operations
normal.bind(KeyCode::Char('x'), none, "clear-cell");
normal.bind(KeyCode::Char('p'), none, "paste");
// View
normal.bind(KeyCode::Char('t'), none, "transpose");
// Mode changes
normal.bind(KeyCode::Char('q'), ctrl, "force-quit");
normal.bind_args(
KeyCode::Char(':'),
none,
"enter-mode",
vec!["command".into()],
);
normal.bind(KeyCode::Char('/'), none, "search");
normal.bind(KeyCode::Char('s'), ctrl, "save");
normal.bind(KeyCode::F(1), none, "enter-mode");
normal.bind_args(KeyCode::F(1), none, "enter-mode", vec!["help".into()]);
normal.bind_args(KeyCode::Char('?'), none, "enter-mode", vec!["help".into()]);
// Panel toggles
normal.bind_args(
KeyCode::Char('F'),
none,
"toggle-panel-and-focus",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('C'),
none,
"toggle-panel-and-focus",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('V'),
none,
"toggle-panel-and-focus",
vec!["view".into()],
);
normal.bind_args(
KeyCode::Char('f'),
ctrl,
"toggle-panel-visibility",
vec!["formula".into()],
);
normal.bind_args(
KeyCode::Char('c'),
ctrl,
"toggle-panel-visibility",
vec!["category".into()],
);
normal.bind_args(
KeyCode::Char('v'),
ctrl,
"toggle-panel-visibility",
vec!["view".into()],
);
normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
// Editing entry
normal.bind(KeyCode::Char('i'), none, "enter-edit-mode");
normal.bind(KeyCode::Char('a'), none, "enter-edit-mode");
normal.bind(KeyCode::Enter, none, "enter-advance");
normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
// Search / category add
normal.bind_args(
KeyCode::Char('n'),
none,
"search-navigate",
vec!["forward".into()],
);
normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
// Page navigation
normal.bind(KeyCode::Char(']'), none, "page-next");
normal.bind(KeyCode::Char('['), none, "page-prev");
// Group / hide
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
normal.bind(KeyCode::Char('H'), none, "hide-selected-row-item");
// Drill into aggregated cell / view history
normal.bind(KeyCode::Char('>'), none, "drill-into-cell");
normal.bind(KeyCode::Char('<'), none, "view-back");
// Tile select
normal.bind(KeyCode::Char('T'), none, "enter-tile-select");
normal.bind(KeyCode::Left, ctrl, "enter-tile-select");
normal.bind(KeyCode::Right, ctrl, "enter-tile-select");
normal.bind(KeyCode::Up, ctrl, "enter-tile-select");
normal.bind(KeyCode::Down, ctrl, "enter-tile-select");
// Prefix keys
let mut g_map = Keymap::new();
g_map.bind(KeyCode::Char('g'), none, "jump-first-row");
g_map.bind(KeyCode::Char('z'), none, "toggle-col-group-under-cursor");
normal.bind_prefix(KeyCode::Char('g'), none, Arc::new(g_map));
let mut y_map = Keymap::new();
y_map.bind(KeyCode::Char('y'), none, "yank");
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");
normal.bind_prefix(KeyCode::Char('Z'), none, Arc::new(z_map));
set.insert(ModeKey::Normal, Arc::new(normal));
// ── Help mode ────────────────────────────────────────────────────
let mut help = Keymap::new();
help.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
help.bind_args(
KeyCode::Char('q'),
none,
"enter-mode",
vec!["normal".into()],
);
set.insert(ModeKey::Help, Arc::new(help));
// ── Formula panel ────────────────────────────────────────────────
let mut fp = Keymap::new();
fp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
fp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
fp.bind_args(
key,
none,
"move-panel-cursor",
vec!["formula".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
fp.bind_args(
key,
none,
"move-panel-cursor",
vec!["formula".into(), "1".into()],
);
}
fp.bind(KeyCode::Char('a'), none, "enter-formula-edit");
fp.bind(KeyCode::Char('n'), 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::Delete, none, "delete-formula-at-cursor");
set.insert(ModeKey::FormulaPanel, Arc::new(fp));
// ── Category panel ───────────────────────────────────────────────
let mut cp = Keymap::new();
cp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
cp.bind_args(
key,
none,
"move-panel-cursor",
vec!["category".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
cp.bind_args(
key,
none,
"move-panel-cursor",
vec!["category".into(), "1".into()],
);
}
cp.bind(KeyCode::Enter, none, "cycle-axis-at-cursor");
cp.bind(KeyCode::Char(' '), none, "cycle-axis-at-cursor");
cp.bind_args(
KeyCode::Char('n'),
none,
"enter-mode",
vec!["category-add".into()],
);
cp.bind(KeyCode::Char('a'), none, "open-item-add-at-cursor");
cp.bind(KeyCode::Char('o'), none, "open-item-add-at-cursor");
set.insert(ModeKey::CategoryPanel, Arc::new(cp));
// ── View panel ───────────────────────────────────────────────────
let mut vp = Keymap::new();
vp.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
vp.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
for key in [KeyCode::Up, KeyCode::Char('k')] {
vp.bind_args(
key,
none,
"move-panel-cursor",
vec!["view".into(), "-1".into()],
);
}
for key in [KeyCode::Down, KeyCode::Char('j')] {
vp.bind_args(
key,
none,
"move-panel-cursor",
vec!["view".into(), "1".into()],
);
}
vp.bind(KeyCode::Enter, none, "switch-view-at-cursor");
vp.bind(KeyCode::Char('n'), 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::Delete, none, "delete-view-at-cursor");
set.insert(ModeKey::ViewPanel, Arc::new(vp));
// ── Tile select ──────────────────────────────────────────────────
let mut ts = Keymap::new();
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
ts.bind_args(
KeyCode::Char('h'),
none,
"move-tile-cursor",
vec!["-1".into()],
);
ts.bind_args(KeyCode::Right, none, "move-tile-cursor", vec!["1".into()]);
ts.bind_args(
KeyCode::Char('l'),
none,
"move-tile-cursor",
vec!["1".into()],
);
ts.bind(KeyCode::Enter, none, "cycle-axis-for-tile");
ts.bind(KeyCode::Char(' '), none, "cycle-axis-for-tile");
ts.bind_args(
KeyCode::Char('r'),
none,
"set-axis-for-tile",
vec!["row".into()],
);
ts.bind_args(
KeyCode::Char('c'),
none,
"set-axis-for-tile",
vec!["column".into()],
);
ts.bind_args(
KeyCode::Char('p'),
none,
"set-axis-for-tile",
vec!["page".into()],
);
ts.bind_args(
KeyCode::Char('n'),
none,
"set-axis-for-tile",
vec!["none".into()],
);
set.insert(ModeKey::TileSelect, Arc::new(ts));
// ── Editing mode ─────────────────────────────────────────────────
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_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));
// ── Formula edit ─────────────────────────────────────────────────
let mut fe = Keymap::new();
fe.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["formula-panel".into()],
);
fe.bind(KeyCode::Enter, none, "commit-formula");
fe.bind_args(KeyCode::Backspace, none, "pop-char", vec!["formula".into()]);
fe.bind_any_char("append-char", vec!["formula".into()]);
set.insert(ModeKey::FormulaEdit, Arc::new(fe));
// ── Category add ─────────────────────────────────────────────────
let mut ca = Keymap::new();
ca.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["category-panel".into()],
);
ca.bind(KeyCode::Enter, none, "commit-category-add");
ca.bind(KeyCode::Tab, none, "commit-category-add");
ca.bind_args(
KeyCode::Backspace,
none,
"pop-char",
vec!["category".into()],
);
ca.bind_any_char("append-char", vec!["category".into()]);
set.insert(ModeKey::CategoryAdd, Arc::new(ca));
// ── Item add ─────────────────────────────────────────────────────
let mut ia = Keymap::new();
ia.bind_args(
KeyCode::Esc,
none,
"enter-mode",
vec!["category-panel".into()],
);
ia.bind(KeyCode::Enter, none, "commit-item-add");
ia.bind(KeyCode::Tab, none, "commit-item-add");
ia.bind_args(KeyCode::Backspace, none, "pop-char", vec!["item".into()]);
ia.bind_any_char("append-char", vec!["item".into()]);
set.insert(ModeKey::ItemAdd, Arc::new(ia));
// ── Export prompt ────────────────────────────────────────────────
let mut ep = Keymap::new();
ep.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
ep.bind(KeyCode::Enter, none, "commit-export");
ep.bind_args(KeyCode::Backspace, none, "pop-char", vec!["export".into()]);
ep.bind_any_char("append-char", vec!["export".into()]);
set.insert(ModeKey::ExportPrompt, Arc::new(ep));
// ── Command mode ─────────────────────────────────────────────────
let mut cm = Keymap::new();
cm.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
cm.bind(KeyCode::Enter, none, "execute-command");
cm.bind(KeyCode::Backspace, none, "command-mode-backspace");
cm.bind_any_char("append-char", vec!["command".into()]);
set.insert(ModeKey::CommandMode, Arc::new(cm));
// ── Search mode ──────────────────────────────────────────────────
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![]);
set.insert(ModeKey::SearchMode, Arc::new(sm));
// ── Import wizard ────────────────────────────────────────────────
let mut wiz = Keymap::new();
wiz.bind_any("handle-wizard-key");
set.insert(ModeKey::ImportWizard, Arc::new(wiz));
set
}
}

View File

@ -1,12 +1,12 @@
//! Command layer — all model mutations go through this layer so they can be
//! replayed, scripted, and tested without the TUI.
//!
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
//! The headless CLI (--cmd / --script) routes through here, and the TUI
//! App also calls dispatch() for every user action that mutates state.
//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`).
//! The headless CLI (--cmd / --script) parses quasi-lisp text into effects
//! and applies them directly.
pub mod dispatch;
pub mod types;
pub mod cmd;
pub mod keymap;
pub mod parse;
pub use dispatch::dispatch;
pub use types::{Command, CommandResult};
pub use parse::parse_line;

184
src/command/parse.rs Normal file
View File

@ -0,0 +1,184 @@
//! Quasi-lisp prefix command parser.
//!
//! Syntax: `word arg1 arg2 ...`
//! Multiple commands on one line separated by `.`
//! Coordinate pairs use `/`: `Category/Item`
//! Quoted strings supported: `"Profit = Revenue - Cost"`
use super::cmd::{default_registry, Cmd, CmdRegistry};
/// Parse a line into commands using the default registry.
pub fn parse_line(line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let registry = default_registry();
parse_line_with(&registry, line)
}
/// Parse a line into commands using a given registry.
pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result<Vec<Box<dyn Cmd>>, String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
return Ok(vec![]);
}
let mut commands = Vec::new();
for segment in split_on_dot(line) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let tokens = tokenize(segment);
if tokens.is_empty() {
continue;
}
let word = &tokens[0];
let args = &tokens[1..];
commands.push(registry.parse(word, args)?);
}
Ok(commands)
}
/// Split a line on ` . ` separators (dot must be a standalone word,
/// surrounded by whitespace or at line boundaries). Respects quoted strings.
fn split_on_dot(line: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let mut in_quote = false;
let bytes = line.as_bytes();
for (i, c) in line.char_indices() {
match c {
'"' => in_quote = !in_quote,
'.' if !in_quote => {
let before_ws = i == 0 || bytes[i - 1].is_ascii_whitespace();
let after_ws = i + 1 >= bytes.len() || bytes[i + 1].is_ascii_whitespace();
if before_ws && after_ws {
segments.push(&line[start..i]);
start = i + 1;
}
}
_ => {}
}
}
segments.push(&line[start..]);
segments
}
/// Tokenize a command segment into words, handling quoted strings.
fn tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
chars.next(); // consume opening quote
let mut s = String::new();
for ch in chars.by_ref() {
if ch == '"' {
break;
}
s.push(ch);
}
tokens.push(s);
} else {
let mut s = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
break;
}
s.push(ch);
chars.next();
}
tokens.push(s);
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_add_category() {
let cmds = parse_line("add-category Region").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-category");
}
#[test]
fn parse_add_item() {
let cmds = parse_line("add-item Region East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-item");
}
#[test]
fn parse_set_cell_number() {
let cmds = parse_line("set-cell 100 Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_set_cell_text() {
let cmds = parse_line("set-cell hello Region/East").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "set-cell");
}
#[test]
fn parse_multiple_commands_dot_separated() {
let cmds = parse_line("add-category Region . add-item Region East").unwrap();
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name(), "add-category");
assert_eq!(cmds[1].name(), "add-item");
}
#[test]
fn parse_quoted_string() {
let cmds = parse_line(r#"add-formula Measure "Profit = Revenue - Cost""#).unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "add-formula");
}
#[test]
fn parse_set_axis() {
let cmds = parse_line("set-axis Payee row").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_set_axis_none() {
let cmds = parse_line("set-axis Date none").unwrap();
assert_eq!(cmds[0].name(), "set-axis");
}
#[test]
fn parse_clear_cell() {
let cmds = parse_line("clear-cell Region/East Measure/Revenue").unwrap();
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name(), "clear-cell");
}
#[test]
fn parse_comments_and_blank_lines() {
assert!(parse_line("").unwrap().is_empty());
assert!(parse_line("# comment").unwrap().is_empty());
assert!(parse_line("// comment").unwrap().is_empty());
}
#[test]
fn parse_unknown_command_errors() {
assert!(parse_line("frobnicate foo").is_err());
}
#[test]
fn parse_missing_args_errors() {
assert!(parse_line("add-category").is_err());
assert!(parse_line("set-cell 100").is_err());
}
}

View File

@ -1,124 +0,0 @@
use crate::view::Axis;
use serde::{Deserialize, Serialize};
/// All commands that can mutate a Model.
///
/// Serialized as `{"op": "<variant>", ...rest}` where `rest` contains
/// the variant's fields flattened into the same JSON object.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op")]
pub enum Command {
/// Add a category (dimension).
AddCategory { name: String },
/// Add an item to a category.
AddItem { category: String, item: String },
/// Add an item inside a named group.
AddItemInGroup {
category: String,
item: String,
group: String,
},
/// Set a cell value. `coords` is a list of `[category, item]` pairs.
SetCell {
coords: Vec<[String; 2]>,
#[serde(flatten)]
value: CellValueArg,
},
/// Clear a cell.
ClearCell { coords: Vec<[String; 2]> },
/// Add or replace a formula.
/// `raw` is the full formula string, e.g. "Profit = Revenue - Cost".
/// `target_category` names the category that owns the formula target.
AddFormula {
raw: String,
target_category: String,
},
/// Remove a formula by its target name and category.
RemoveFormula {
target: String,
target_category: String,
},
/// Create a new view.
CreateView { name: String },
/// Delete a view.
DeleteView { name: String },
/// Switch the active view.
SwitchView { name: String },
/// Set the axis of a category in the active view.
SetAxis { category: String, axis: Axis },
/// Set the page-axis selection for a category.
SetPageSelection { category: String, item: String },
/// Toggle collapse of a group in the active view.
ToggleGroup { category: String, group: String },
/// Hide an item in the active view.
HideItem { category: String, item: String },
/// Show (un-hide) an item in the active view.
ShowItem { category: String, item: String },
/// Save the model to a file path.
Save { path: String },
/// Load a model from a file path (replaces current model).
Load { path: String },
/// Export the active view to CSV.
ExportCsv { path: String },
/// Import a JSON file via the analyzer (non-interactive, uses auto-detected proposals).
ImportJson {
path: String,
model_name: Option<String>,
/// Dot-path to the records array (empty = root)
array_path: Option<String>,
},
}
/// Inline value for SetCell
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CellValueArg {
Number { number: f64 },
Text { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl CommandResult {
pub fn ok() -> Self {
Self {
ok: true,
message: None,
}
}
pub fn ok_msg(msg: impl Into<String>) -> Self {
Self {
ok: true,
message: Some(msg.into()),
}
}
pub fn err(msg: impl Into<String>) -> Self {
Self {
ok: false,
message: Some(msg.into()),
}
}
}

400
src/draw.rs Normal file
View File

@ -0,0 +1,400 @@
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use crate::model::Model;
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::CategoryPanel;
use crate::ui::formula_panel::FormulaPanel;
use crate::ui::grid::GridWidget;
use crate::ui::help::HelpWidget;
use crate::ui::import_wizard_ui::ImportWizardWidget;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewPanel;
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
pub fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn fill_line(left: String, right: &str, width: u16) -> String {
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
format!("{left}{pad}{right}")
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w, h)
}
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup);
f.render_widget(block, popup);
inner
}
fn mode_name(mode: &AppMode) -> &'static str {
match mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget, size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard {
f.render_widget(ImportWizardWidget::new(wizard), size);
}
}
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
draw_export_prompt(f, size, app);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let file = app
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let grid_area;
if side_open {
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
grid_area = chunks[0];
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
a,
);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
a,
);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
a,
);
}
} else {
grid_area = area;
}
f.render_widget(
GridWidget::new(
&app.model,
&app.mode,
&app.search_query,
&app.buffers,
app.drill_state.as_ref(),
),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode, app.tile_cat_idx), area);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
match app.mode {
AppMode::CommandMode { .. } => {
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
draw_command_bar(f, area, buf);
}
_ => draw_status(f, area, app),
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
f.render_widget(
Paragraph::new(format!(":{buffer}"))
.style(Style::default().fg(Color::White).bg(Color::Black)),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let popup = centered_popup(area, 64, 3);
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -16,7 +16,7 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(|w| parse_where(w)).transpose()?;
let filter = filter.map(parse_where).transpose()?;
let expr = parse_expr(expr_str.trim())?;
@ -299,7 +299,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.to_ascii_uppercase() == "WHERE" {
if kw.eq_ignore_ascii_case("WHERE") {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => {

View File

@ -1,3 +1,4 @@
use chrono::{Datelike, NaiveDate};
use serde_json::Value;
use std::collections::HashSet;
@ -13,12 +14,24 @@ pub enum FieldKind {
Label,
}
/// Date components that can be extracted from a date field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateComponent {
Year,
Month,
Quarter,
}
#[derive(Debug, Clone)]
pub struct FieldProposal {
pub field: String,
pub kind: FieldKind,
pub distinct_values: Vec<String>,
pub accepted: bool,
/// Detected chrono format string (e.g., "%m/%d/%Y"). Only set for TimeCategory.
pub date_format: Option<String>,
/// Which date components to extract as new categories.
pub date_components: Vec<DateComponent>,
}
impl FieldProposal {
@ -32,6 +45,55 @@ impl FieldProposal {
}
}
/// Common date formats to try, in order of preference.
const DATE_FORMATS: &[&str] = &[
"%Y-%m-%d", // 2025-04-02
"%m/%d/%Y", // 04/02/2025
"%m/%d/%y", // 04/02/25
"%d/%m/%Y", // 02/04/2025
"%Y%m%d", // 20250402
"%b %d, %Y", // Apr 02, 2025
"%B %d, %Y", // April 02, 2025
"%d-%b-%Y", // 02-Apr-2025
];
/// Try to detect a chrono date format from sample values.
/// Returns the first format that successfully parses all non-empty samples.
pub fn detect_date_format(samples: &[&str]) -> Option<String> {
let samples: Vec<&str> = samples.iter().copied().filter(|s| !s.is_empty()).collect();
if samples.is_empty() {
return None;
}
// Try up to 10 samples for efficiency
let test_samples: Vec<&str> = samples.into_iter().take(10).collect();
for fmt in DATE_FORMATS {
if test_samples
.iter()
.all(|s| NaiveDate::parse_from_str(s, fmt).is_ok())
{
return Some(fmt.to_string());
}
}
None
}
/// Parse a date string and extract a component value.
pub fn extract_date_component(
value: &str,
format: &str,
component: DateComponent,
) -> Option<String> {
let date = NaiveDate::parse_from_str(value, format).ok()?;
Some(match component {
DateComponent::Year => format!("{}", date.format("%Y")),
DateComponent::Month => format!("{}", date.format("%Y-%m")),
DateComponent::Quarter => {
let q = (date.month0() / 3) + 1;
format!("{}-Q{}", date.format("%Y"), q)
}
})
}
const CATEGORY_THRESHOLD: usize = 20;
pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
@ -65,6 +127,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Measure,
distinct_values: vec![],
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -72,26 +136,19 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
let distinct: HashSet<&str> = values.iter().filter_map(|v| v.as_str()).collect();
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
let n = distinct_vec.len();
let _total = values.len();
// Check if looks like date
let looks_like_date = distinct_vec.iter().any(|s| {
s.contains('-') && s.len() >= 8
|| s.starts_with("Q") && s.len() == 2
|| [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
"Nov", "Dec",
]
.iter()
.any(|m| s.starts_with(m))
});
// Try chrono-based date detection
let samples: Vec<&str> = distinct_vec.iter().map(|s| s.as_str()).collect();
let date_format = detect_date_format(&samples);
if looks_like_date {
if date_format.is_some() {
return FieldProposal {
field,
kind: FieldKind::TimeCategory,
distinct_values: distinct_vec,
accepted: true,
date_format,
date_components: vec![],
};
}
@ -101,6 +158,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Category,
distinct_values: distinct_vec,
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -109,6 +168,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Label,
distinct_values: distinct_vec,
accepted: false,
date_format: None,
date_components: vec![],
};
}
@ -118,6 +179,8 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Label,
distinct_values: vec![],
accepted: false,
date_format: None,
date_components: vec![],
}
})
.collect()
@ -160,3 +223,70 @@ fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>)
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_iso_date_format() {
let samples = vec!["2025-01-15", "2025-02-28", "2024-12-01"];
assert_eq!(detect_date_format(&samples), Some("%Y-%m-%d".to_string()));
}
#[test]
fn detect_us_date_format() {
let samples = vec!["03/31/2026", "01/15/2025", "12/25/2024"];
assert_eq!(detect_date_format(&samples), Some("%m/%d/%Y".to_string()));
}
#[test]
fn detect_short_year_format() {
// Two-digit years are ambiguous with four-digit format, so %m/%d/%Y
// matches first. This is expected — the user can override in the wizard.
let samples = vec!["03/31/26", "01/15/25"];
assert!(detect_date_format(&samples).is_some());
}
#[test]
fn detect_no_date_format() {
let samples = vec!["hello", "world"];
assert_eq!(detect_date_format(&samples), None);
}
#[test]
fn extract_year_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Year);
assert_eq!(result, Some("2026".to_string()));
}
#[test]
fn extract_month_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Month);
assert_eq!(result, Some("2026-03".to_string()));
}
#[test]
fn extract_quarter_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2026-Q1".to_string()));
}
#[test]
fn extract_quarter_q4() {
let result = extract_date_component("12/15/2025", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2025-Q4".to_string()));
}
#[test]
fn analyze_detects_time_category_with_format() {
let records: Vec<Value> = vec![
serde_json::json!({"Date": "01/15/2025", "Amount": 100}),
serde_json::json!({"Date": "02/20/2025", "Amount": 200}),
];
let proposals = analyze_records(&records);
let date_prop = proposals.iter().find(|p| p.field == "Date").unwrap();
assert_eq!(date_prop.kind, FieldKind::TimeCategory);
assert_eq!(date_prop.date_format, Some("%m/%d/%Y".to_string()));
}
}

244
src/import/csv_parser.rs Normal file
View File

@ -0,0 +1,244 @@
use std::path::Path;
use anyhow::{Context, Result};
use csv::ReaderBuilder;
use serde_json::Value;
pub fn csv_path_p(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"))
}
/// Parse a CSV file and return records as serde_json::Value array
pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
let mut reader = ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.trim(csv::Trim::All)
.from_path(path)
.with_context(|| format!("Failed to open CSV file: {}", path.display()))?;
// Detect if first row looks like headers (strings) or data (mixed)
let has_headers = reader.headers().is_ok();
let mut records = Vec::new();
let mut headers = Vec::new();
if has_headers {
headers = reader
.headers()
.with_context(|| "Failed to read CSV headers")?
.iter()
.map(|s| s.to_string())
.collect();
}
for result in reader.records() {
let record = result.with_context(|| "Failed to read CSV record")?;
let mut map = serde_json::Map::new();
for (i, field) in record.iter().enumerate() {
let json_value: Value = parse_csv_field(field);
if has_headers {
if let Some(header) = headers.get(i) {
map.insert(header.clone(), json_value);
}
} else {
map.insert(i.to_string(), json_value);
}
}
if !map.is_empty() {
records.push(Value::Object(map));
}
}
Ok(records)
}
/// Parse multiple CSV files and merge into a single JSON array.
/// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv").
pub fn merge_csvs(paths: &[impl AsRef<Path>]) -> Result<Vec<Value>> {
let mut all_records = Vec::new();
for path in paths {
let path = path.as_ref();
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let records = parse_csv(path)?;
for mut record in records {
if let Value::Object(ref mut map) = record {
map.insert("File".to_string(), Value::String(stem.clone()));
}
all_records.push(record);
}
}
Ok(all_records)
}
fn parse_csv_field(field: &str) -> Value {
if field.is_empty() {
return Value::Null;
}
// Try to parse as number (integer or float)
if let Ok(num) = field.parse::<i64>() {
return Value::Number(serde_json::Number::from(num));
}
if let Ok(num) = field.parse::<f64>() {
return Value::Number(
serde_json::Number::from_f64(num).unwrap_or(serde_json::Number::from(0)),
);
}
// Otherwise treat as string
Value::String(field.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, path::PathBuf};
use tempfile::tempdir;
fn create_temp_csv(content: &str) -> (PathBuf, tempfile::TempDir) {
let dir = tempdir().unwrap();
let path = dir.path().join("test.csv");
fs::write(&path, content).unwrap();
(path, dir)
}
#[test]
fn parse_simple_csv() {
let (path, _dir) =
create_temp_csv("Region,Product,Revenue\nEast,Shirts,1000\nWest,Shirts,800");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
assert_eq!(records[0]["Product"], Value::String("Shirts".to_string()));
assert_eq!(
records[0]["Revenue"],
Value::Number(serde_json::Number::from(1000))
);
}
#[test]
fn parse_csv_with_floats() {
let (path, _dir) =
create_temp_csv("Region,Revenue,Cost\nEast,1000.50,600.25\nWest,800.75,500.00");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert!(records[0]["Revenue"].is_f64());
assert_eq!(
records[0]["Revenue"],
Value::Number(serde_json::Number::from_f64(1000.50).unwrap())
);
}
#[test]
fn parse_csv_with_quoted_fields() {
let (path, _dir) =
create_temp_csv("Product,Description,Price\n\"Shirts\",\"A nice shirt\",10.00");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["Product"], Value::String("Shirts".to_string()));
assert_eq!(
records[0]["Description"],
Value::String("A nice shirt".to_string())
);
}
#[test]
fn parse_csv_with_empty_values() {
let (path, _dir) = create_temp_csv("Region,Product,Revenue\nEast,,1000\nWest,Shirts,");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Product"], Value::Null);
assert_eq!(records[1]["Revenue"], Value::Null);
}
#[test]
fn parse_csv_mixed_types() {
let (path, _dir) =
create_temp_csv("Name,Count,Price,Active\nWidget,5,9.99,true\nGadget,3,19.99,false");
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Name"], Value::String("Widget".to_string()));
assert_eq!(
records[0]["Count"],
Value::Number(serde_json::Number::from(5))
);
assert!(records[0]["Price"].is_f64());
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
}
#[test]
fn merge_csvs_adds_file_field_from_stem() {
let dir = tempdir().unwrap();
let sales = dir.path().join("sales.csv");
let expenses = dir.path().join("expenses.csv");
fs::write(&sales, "Region,Revenue\nEast,100\nWest,200").unwrap();
fs::write(&expenses, "Region,Revenue\nEast,50\nWest,75").unwrap();
let records = merge_csvs(&[sales, expenses]).unwrap();
assert_eq!(records.len(), 4);
assert_eq!(records[0]["File"], Value::String("sales".to_string()));
assert_eq!(records[1]["File"], Value::String("sales".to_string()));
assert_eq!(records[2]["File"], Value::String("expenses".to_string()));
assert_eq!(records[3]["File"], Value::String("expenses".to_string()));
// Original fields preserved
assert_eq!(records[0]["Region"], Value::String("East".to_string()));
assert_eq!(
records[2]["Revenue"],
Value::Number(serde_json::Number::from(50))
);
}
#[test]
fn merge_csvs_single_file_works() {
let dir = tempdir().unwrap();
let path = dir.path().join("data.csv");
fs::write(&path, "Name,Value\nA,1").unwrap();
let records = merge_csvs(&[path]).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["File"], Value::String("data".to_string()));
assert_eq!(records[0]["Name"], Value::String("A".to_string()));
}
#[test]
fn parse_checking_csv_format() {
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
let (path, _dir) = create_temp_csv(
"Date,Amount,Flag,CheckNo,Description\n\
\"03/31/2026\",\"-50.00\",\"*\",\"\",\"VENMO PAYMENT 260331\"\n\
\"03/31/2026\",\"-240.00\",\"*\",\"\",\"ROBINHOOD DEBITS XXXXX3795\"",
);
let records = parse_csv(&path).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["Date"], Value::String("03/31/2026".to_string()));
assert_eq!(
records[0]["Amount"],
Value::Number(serde_json::Number::from_f64(-50.00).unwrap())
);
assert_eq!(records[0]["Flag"], Value::String("*".to_string()));
assert_eq!(records[0]["CheckNo"], Value::Null);
assert_eq!(
records[0]["Description"],
Value::String("VENMO PAYMENT 260331".to_string())
);
assert_eq!(
records[1]["Amount"],
Value::Number(serde_json::Number::from_f64(-240.00).unwrap())
);
}
}

View File

@ -1,2 +1,3 @@
pub mod analyzer;
pub mod csv_parser;
pub mod wizard;

View File

@ -2,8 +2,10 @@ use anyhow::{anyhow, Result};
use serde_json::Value;
use super::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
DateComponent, FieldKind, FieldProposal,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
@ -19,6 +21,8 @@ pub struct ImportPipeline {
pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>,
pub model_name: String,
/// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost").
pub formulas: Vec<String>,
}
impl ImportPipeline {
@ -31,6 +35,7 @@ impl ImportPipeline {
records: vec![],
proposals: vec![],
model_name: "Imported Model".to_string(),
formulas: vec![],
};
// Auto-select if root is an array or there is exactly one candidate path.
@ -94,6 +99,30 @@ impl ImportPipeline {
return Err(anyhow!("At least one category must be accepted"));
}
// Collect date component extractions: (field_name, format, component, derived_cat_name)
let date_extractions: Vec<(&str, &str, DateComponent, String)> = self
.proposals
.iter()
.filter(|p| {
p.accepted
&& p.kind == FieldKind::TimeCategory
&& p.date_format.is_some()
&& !p.date_components.is_empty()
})
.flat_map(|p| {
let fmt = p.date_format.as_deref().unwrap();
p.date_components.iter().map(move |comp| {
let suffix = match comp {
DateComponent::Year => "Year",
DateComponent::Month => "Month",
DateComponent::Quarter => "Quarter",
};
let derived_name = format!("{}_{}", p.field, suffix);
(p.field.as_str(), fmt, *comp, derived_name)
})
})
.collect();
let mut model = Model::new(&self.model_name);
for cat_proposal in &categories {
@ -105,6 +134,11 @@ impl ImportPipeline {
}
}
// Create derived date-component categories
for (_, _, _, ref derived_name) in &date_extractions {
model.add_category(derived_name)?;
}
if !measures.is_empty() {
model.add_category("Measure")?;
if let Some(cat) = model.category_mut("Measure") {
@ -130,7 +164,19 @@ impl ImportPipeline {
if let Some(cat) = model.category_mut(&cat_proposal.field) {
cat.add_item(&v);
}
coords.push((cat_proposal.field.clone(), v));
coords.push((cat_proposal.field.clone(), v.clone()));
// Extract date components from this field's value
for (field, fmt, comp, ref derived_name) in &date_extractions {
if *field == cat_proposal.field {
if let Some(derived_val) = extract_date_component(&v, fmt, *comp) {
if let Some(cat) = model.category_mut(derived_name) {
cat.add_item(&derived_val);
}
coords.push((derived_name.clone(), derived_val));
}
}
}
} else {
valid = false;
break;
@ -151,6 +197,24 @@ impl ImportPipeline {
}
}
// Parse and add formulas
// Formulas target the "Measure" category by default.
let formula_cat: String = if model.category("Measure").is_some() {
"Measure".to_string()
} else {
model
.categories
.keys()
.next()
.cloned()
.unwrap_or_else(|| "Measure".to_string())
};
for raw in &self.formulas {
if let Ok(formula) = parse_formula(raw, &formula_cat) {
model.add_formula(formula);
}
}
Ok(model)
}
}
@ -162,6 +226,8 @@ pub enum WizardStep {
Preview,
SelectArrayPath,
ReviewProposals,
ConfigureDates,
DefineFormulas,
NameModel,
Done,
}
@ -177,6 +243,10 @@ pub struct ImportWizard {
pub cursor: usize,
/// One-line message to display at the bottom of the wizard panel.
pub message: Option<String>,
/// Whether we're in formula text-input mode.
pub formula_editing: bool,
/// Buffer for the formula being typed.
pub formula_buffer: String,
}
impl ImportWizard {
@ -196,6 +266,8 @@ impl ImportWizard {
step,
cursor: 0,
message: None,
formula_editing: false,
formula_buffer: String::new(),
}
}
@ -211,7 +283,15 @@ impl ImportWizard {
}
}
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
WizardStep::ReviewProposals => WizardStep::NameModel,
WizardStep::ReviewProposals => {
if self.has_time_categories() {
WizardStep::ConfigureDates
} else {
WizardStep::DefineFormulas
}
}
WizardStep::ConfigureDates => WizardStep::DefineFormulas,
WizardStep::DefineFormulas => WizardStep::NameModel,
WizardStep::NameModel => WizardStep::Done,
WizardStep::Done => WizardStep::Done,
};
@ -219,6 +299,22 @@ impl ImportWizard {
self.message = None;
}
fn has_time_categories(&self) -> bool {
self.pipeline
.proposals
.iter()
.any(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
}
/// Get accepted TimeCategory proposals (for ConfigureDates step).
pub fn time_category_proposals(&self) -> Vec<&FieldProposal> {
self.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
.collect()
}
pub fn confirm_path(&mut self) {
if self.cursor < self.pipeline.array_paths.len() {
let path = self.pipeline.array_paths[self.cursor].clone();
@ -233,6 +329,8 @@ impl ImportWizard {
let len = match self.step {
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
WizardStep::ConfigureDates => self.date_config_item_count(),
WizardStep::DefineFormulas => self.pipeline.formulas.len(),
_ => 0,
};
if len == 0 {
@ -275,6 +373,130 @@ impl ImportWizard {
self.pipeline.model_name.pop();
}
// ── Date config ────────────────────────────────────────────────────────────
/// Total number of items in the ConfigureDates list.
/// Each TimeCategory field gets 3 rows (Year, Month, Quarter).
fn date_config_item_count(&self) -> usize {
self.time_category_proposals().len() * 3
}
/// Get the (field_index, component) for the current cursor position.
pub fn date_config_at_cursor(&self) -> Option<(usize, DateComponent)> {
let tc_indices = self.time_category_indices();
if tc_indices.is_empty() {
return None;
}
let field_idx = self.cursor / 3;
let comp_idx = self.cursor % 3;
let component = match comp_idx {
0 => DateComponent::Year,
1 => DateComponent::Month,
_ => DateComponent::Quarter,
};
tc_indices.get(field_idx).map(|&pi| (pi, component))
}
/// Indices into pipeline.proposals for accepted TimeCategory fields.
fn time_category_indices(&self) -> Vec<usize> {
self.pipeline
.proposals
.iter()
.enumerate()
.filter(|(_, p)| {
p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some()
})
.map(|(i, _)| i)
.collect()
}
/// Toggle a date component for the field at the current cursor.
pub fn toggle_date_component(&mut self) {
if let Some((pi, component)) = self.date_config_at_cursor() {
let proposal = &mut self.pipeline.proposals[pi];
if let Some(pos) = proposal
.date_components
.iter()
.position(|c| *c == component)
{
proposal.date_components.remove(pos);
} else {
proposal.date_components.push(component);
}
}
}
// ── Formula editing ────────────────────────────────────────────────────────
/// Buffer for typing a new formula in the DefineFormulas step.
pub fn push_formula_char(&mut self, c: char) {
if !self.formula_editing {
self.formula_editing = true;
self.formula_buffer.clear();
}
self.formula_buffer.push(c);
}
pub fn pop_formula_char(&mut self) {
self.formula_buffer.pop();
}
/// Commit the current formula buffer to the pipeline's formula list.
pub fn confirm_formula(&mut self) {
let text = self.formula_buffer.trim().to_string();
if !text.is_empty() {
self.pipeline.formulas.push(text);
}
self.formula_buffer.clear();
self.formula_editing = false;
self.cursor = self.pipeline.formulas.len().saturating_sub(1);
}
/// Delete the formula at the current cursor position.
pub fn delete_formula(&mut self) {
if self.cursor < self.pipeline.formulas.len() {
self.pipeline.formulas.remove(self.cursor);
if self.cursor > 0 && self.cursor >= self.pipeline.formulas.len() {
self.cursor -= 1;
}
}
}
/// Start editing a new formula.
pub fn start_formula_edit(&mut self) {
self.formula_editing = true;
self.formula_buffer.clear();
}
/// Cancel formula editing.
pub fn cancel_formula_edit(&mut self) {
self.formula_editing = false;
self.formula_buffer.clear();
}
/// Generate sample formulas based on accepted measures.
pub fn sample_formulas(&self) -> Vec<String> {
let measures: Vec<&str> = self
.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
.map(|p| p.field.as_str())
.collect();
let mut samples = Vec::new();
if measures.len() >= 2 {
samples.push(format!("Diff = {} - {}", measures[0], measures[1]));
}
if !measures.is_empty() {
samples.push(format!("Total = SUM({})", measures[0]));
}
if measures.len() >= 2 {
samples.push(format!("Ratio = {} / {}", measures[0], measures[1]));
}
samples
}
// ── Delegate build to pipeline ────────────────────────────────────────────
pub fn build_model(&self) -> Result<Model> {
@ -410,4 +632,70 @@ mod tests {
let p = ImportPipeline::new(raw);
assert_eq!(p.model_name, "Imported Model");
}
#[test]
fn build_model_adds_formulas_from_pipeline() {
let raw = json!([
{"region": "East", "revenue": 100.0, "cost": 40.0},
{"region": "West", "revenue": 200.0, "cost": 80.0},
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Profit = revenue - cost".to_string());
let model = p.build_model().unwrap();
// The formula should produce Profit = 60 for East (100-40)
use crate::model::cell::CellKey;
let key = CellKey::new(vec![
("Measure".to_string(), "Profit".to_string()),
("region".to_string(), "East".to_string()),
]);
let val = model.evaluate(&key).and_then(|v| v.as_f64());
assert_eq!(val, Some(60.0));
}
#[test]
fn build_model_extracts_date_month_component() {
use crate::import::analyzer::DateComponent;
let raw = json!([
{"Date": "01/15/2025", "Amount": 100.0},
{"Date": "01/20/2025", "Amount": 50.0},
{"Date": "02/05/2025", "Amount": 200.0},
]);
let mut p = ImportPipeline::new(raw);
// Enable Month extraction on the Date field
for prop in &mut p.proposals {
if prop.field == "Date" && prop.kind == FieldKind::TimeCategory {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
assert!(model.category("Date_Month").is_some());
let cat = model.category("Date_Month").unwrap();
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
assert!(items.contains(&"2025-01"));
assert!(items.contains(&"2025-02"));
}
#[test]
fn build_model_date_components_appear_in_cell_keys() {
use crate::import::analyzer::DateComponent;
use crate::model::cell::CellKey;
let raw = json!([
{"Date": "03/31/2026", "Amount": 100.0},
]);
let mut p = ImportPipeline::new(raw);
for prop in &mut p.proposals {
if prop.field == "Date" {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
let key = CellKey::new(vec![
("Date".to_string(), "03/31/2026".to_string()),
("Date_Month".to_string(), "2026-03".to_string()),
("Measure".to_string(), "Amount".to_string()),
]);
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
}
}

View File

@ -1,4 +1,5 @@
mod command;
mod draw;
mod formula;
mod import;
mod model;
@ -6,200 +7,350 @@ mod persistence;
mod ui;
mod view;
use std::io::{self, Stdout};
use crate::import::csv_parser::csv_path_p;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use clap::{Parser, Subcommand};
use draw::run_tui;
use model::Model;
use ui::app::{App, AppMode};
use ui::category_panel::CategoryPanel;
use ui::formula_panel::FormulaPanel;
use ui::grid::GridWidget;
use ui::help::HelpWidget;
use ui::import_wizard_ui::ImportWizardWidget;
use ui::tile_bar::TileBar;
use ui::view_panel::ViewPanel;
use serde_json::Value;
#[derive(Parser)]
#[command(name = "improvise", about = "Multi-dimensional data modeling TUI")]
struct Cli {
/// Model file to open or create
file: Option<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// Import JSON or CSV data, then open TUI (or save with --output)
Import {
/// Files to import (multiple CSVs merge with a "File" category)
files: Vec<PathBuf>,
/// Mark field as category dimension (repeatable)
#[arg(long)]
category: Vec<String>,
/// Mark field as numeric measure (repeatable)
#[arg(long)]
measure: Vec<String>,
/// Mark field as time/date category (repeatable)
#[arg(long)]
time: Vec<String>,
/// Skip/exclude a field from import (repeatable)
#[arg(long)]
skip: Vec<String>,
/// Extract date component, e.g. "Date:Month" (repeatable)
#[arg(long)]
extract: Vec<String>,
/// Set category axis, e.g. "Payee:row" (repeatable)
#[arg(long)]
axis: Vec<String>,
/// Add formula, e.g. "Profit = Revenue - Cost" (repeatable)
#[arg(long)]
formula: Vec<String>,
/// Model name (default: "Imported Model")
#[arg(long)]
name: Option<String>,
/// Skip the interactive wizard
#[arg(long)]
no_wizard: bool,
/// Save to file instead of opening TUI
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Run a JSON command headless (repeatable)
Cmd {
/// JSON command strings
json: Vec<String>,
/// Model file to load/save
#[arg(short, long)]
file: Option<PathBuf>,
},
/// Run commands from a script file headless
Script {
/// Script file (one JSON command per line, # comments)
path: PathBuf,
/// Model file to load/save
#[arg(short, long)]
file: Option<PathBuf>,
},
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
let cli = Cli::parse();
trait Runnable {
fn run(self: Box<Self>) -> Result<()>;
}
match cli.command {
None => {
let model = get_initial_model(&cli.file)?;
run_tui(model, cli.file, None)
}
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_path: Option<PathBuf>,
}
Some(Commands::Import {
files,
category,
measure,
time,
skip,
extract,
axis,
formula,
name,
no_wizard,
output,
}) => {
let import_value = if files.is_empty() {
anyhow::bail!("No files specified for import");
} else {
get_import_data(&files)
.ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))?
};
impl Runnable for CmdLineArgs {
fn run(self: Box<Self>) -> Result<()> {
// Load or create model
let model = get_initial_model(&self.file_path)?;
let config = ImportConfig {
categories: category,
measures: measure,
time_fields: time,
skip_fields: skip,
extractions: parse_colon_pairs(&extract),
axes: parse_colon_pairs(&axis),
formulas: formula,
name,
};
// Pre-TUI import: parse JSON and open wizard
let import_json = if let Some(ref path) = self.import_path {
match std::fs::read_to_string(path) {
Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display());
return Ok(());
}
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
return Ok(());
}
Ok(json) => Some(json),
},
}
} else {
None
};
run_tui(model, self.file_path, import_json)
}
}
struct HeadlessArgs {
file_path: Option<PathBuf>,
commands: Vec<String>,
script: Option<PathBuf>,
}
impl Runnable for HeadlessArgs {
fn run(self: Box<Self>) -> Result<()> {
let mut model = get_initial_model(&self.file_path)?;
let mut cmds: Vec<String> = self.commands;
if let Some(script_path) = self.script {
let content = std::fs::read_to_string(&script_path)?;
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#') {
cmds.push(trimmed.to_string());
}
if no_wizard {
run_headless_import(import_value, &config, output, cli.file)
} else {
run_wizard_import(import_value, &config, cli.file)
}
}
let mut exit_code = 0;
for raw_cmd in &cmds {
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
Ok(c) => c,
Err(e) => {
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&r)?);
exit_code = 1;
continue;
Some(Commands::Cmd { json, file }) => run_headless_commands(&json, &file),
Some(Commands::Script { path, file }) => run_headless_script(&path, &file),
}
}
// ── Import config ────────────────────────────────────────────────────────────
struct ImportConfig {
categories: Vec<String>,
measures: Vec<String>,
time_fields: Vec<String>,
skip_fields: Vec<String>,
extractions: Vec<(String, String)>,
axes: Vec<(String, String)>,
formulas: Vec<String>,
name: Option<String>,
}
fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> {
args.iter()
.filter_map(|s| {
let (a, b) = s.split_once(':')?;
Some((a.to_string(), b.to_string()))
})
.collect()
}
fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, config: &ImportConfig) {
use import::analyzer::{DateComponent, FieldKind};
// Override field kinds
for p in &mut pipeline.proposals {
if config.categories.contains(&p.field) {
p.kind = FieldKind::Category;
p.accepted = true;
} else if config.measures.contains(&p.field) {
p.kind = FieldKind::Measure;
p.accepted = true;
} else if config.time_fields.contains(&p.field) {
p.kind = FieldKind::TimeCategory;
p.accepted = true;
} else if config.skip_fields.contains(&p.field) {
p.accepted = false;
}
}
// Apply date component extractions
for (field, comp_str) in &config.extractions {
let component = match comp_str.to_lowercase().as_str() {
"year" => DateComponent::Year,
"month" => DateComponent::Month,
"quarter" => DateComponent::Quarter,
_ => continue,
};
for p in &mut pipeline.proposals {
if p.field == *field && !p.date_components.contains(&component) {
p.date_components.push(component);
}
}
}
// Set formulas
pipeline.formulas = config.formulas.clone();
// Set model name
if let Some(ref name) = config.name {
pipeline.model_name = name.clone();
}
}
fn apply_axis_overrides(model: &mut Model, axes: &[(String, String)]) {
use view::Axis;
let view = model.active_view_mut();
for (cat, axis_str) in axes {
let axis = match axis_str.to_lowercase().as_str() {
"row" => Axis::Row,
"column" | "col" => Axis::Column,
"page" => Axis::Page,
"none" => Axis::None,
_ => continue,
};
view.set_axis(cat, axis);
}
}
fn run_headless_import(
import_value: Value,
config: &ImportConfig,
output: Option<PathBuf>,
model_file: Option<PathBuf>,
) -> Result<()> {
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
apply_config_to_pipeline(&mut pipeline, config);
let mut model = pipeline.build_model()?;
model.normalize_view_state();
apply_axis_overrides(&mut model, &config.axes);
if let Some(path) = output.or(model_file) {
persistence::save(&model, &path)?;
eprintln!("Saved to {}", path.display());
} else {
eprintln!("No output path specified; use -o <path> or provide a model file");
}
Ok(())
}
fn run_wizard_import(
import_value: Value,
_config: &ImportConfig,
model_file: Option<PathBuf>,
) -> Result<()> {
let model = get_initial_model(&model_file)?;
// Pre-configure will happen inside the TUI via the wizard
// For now, pass import_value and let the wizard handle it
// TODO: pass config to wizard for pre-population
run_tui(model, model_file, Some(import_value))
}
// ── Import data loading ──────────────────────────────────────────────────────
fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
let all_csv = paths.iter().all(|p| csv_path_p(p));
if paths.len() > 1 {
if !all_csv {
eprintln!("Multi-file import only supports CSV files");
return None;
}
match crate::import::csv_parser::merge_csvs(paths) {
Ok(records) => Some(Value::Array(records)),
Err(e) => {
eprintln!("CSV merge error: {e}");
None
}
}
} else {
let path = &paths[0];
match std::fs::read_to_string(path) {
Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display());
None
}
Ok(content) => {
if csv_path_p(path) {
match crate::import::csv_parser::parse_csv(path) {
Ok(records) => Some(Value::Array(records)),
Err(e) => {
eprintln!("CSV parse error: {e}");
None
}
}
} else {
match serde_json::from_str::<Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
None
}
Ok(json) => Some(json),
}
}
};
let result = command::dispatch(&mut model, &parsed);
if !result.ok {
}
}
}
}
// ── Headless command execution ───────────────────────────────────────────────
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
use crossterm::event::{KeyCode, KeyModifiers};
let model = get_initial_model(file)?;
let mut app = ui::app::App::new(model, file.clone());
let mut exit_code = 0;
for line in cmds {
match command::parse_line(line) {
Ok(parsed_cmds) => {
for cmd in &parsed_cmds {
let effects = {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
cmd.execute(&ctx)
};
app.apply_effects(effects);
}
}
Err(e) => {
eprintln!("Parse error: {e}");
exit_code = 1;
}
println!("{}", serde_json::to_string(&result)?);
}
if let Some(path) = self.file_path {
persistence::save(&mut model, &path)?;
}
std::process::exit(exit_code);
}
if let Some(path) = file {
persistence::save(&app.model, path)?;
}
std::process::exit(exit_code);
}
struct HelpArgs;
impl Runnable for HelpArgs {
fn run(self: Box<Self>) -> Result<()> {
println!("improvise — multi-dimensional data modeling TUI\n");
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model");
println!(" improvise --import data.json Import JSON then open TUI");
println!(" improvise --cmd '{{...}}' Run a JSON command (headless, repeatable)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!("\nTUI KEYS (vim-style):");
println!(" : Command mode (:q :w :import :add-cat :formula …)");
println!(" hjkl / ↑↓←→ Navigate grid");
println!(" i / Enter Edit cell (Insert mode)");
println!(" Esc Return to Normal mode");
println!(" x Clear cell");
println!(" yy / p Yank / paste cell value");
println!(" gg / G First / last row");
println!(" 0 / $ First / last column");
println!(" Ctrl+D/U Scroll half-page down / up");
println!(" / n N Search / next / prev");
println!(" [ ] Cycle page-axis filter");
println!(" T Tile-select (pivot) mode");
println!(" F C V Toggle Formulas / Categories / Views panel");
println!(" ZZ Save and quit");
println!(" ? Help");
Ok(())
}
fn run_headless_script(script_path: &PathBuf, file: &Option<PathBuf>) -> Result<()> {
let content = std::fs::read_to_string(script_path)?;
let lines: Vec<String> = content.lines().map(String::from).collect();
run_headless_commands(&lines, file)
}
fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
let mut file_path: Option<PathBuf> = None;
let mut headless_cmds: Vec<String> = Vec::new();
let mut headless_script: Option<PathBuf> = None;
let mut import_path: Option<PathBuf> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--cmd" | "-c" => {
i += 1;
if let Some(cmd) = args.get(i).cloned() {
headless_cmds.push(cmd);
}
}
"--script" | "-s" => {
i += 1;
headless_script = args.get(i).map(PathBuf::from);
}
"--import" => {
i += 1;
import_path = args.get(i).map(PathBuf::from);
}
"--help" | "-h" => {
return Box::new(HelpArgs);
}
arg if !arg.starts_with('-') => {
file_path = Some(PathBuf::from(arg));
}
_ => {}
}
i += 1;
}
if !headless_cmds.is_empty() || headless_script.is_some() {
Box::new(HeadlessArgs {
file_path,
commands: headless_cmds,
script: headless_script,
})
} else {
Box::new(CmdLineArgs {
file_path,
import_path,
})
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
if let Some(ref path) = file_path {
@ -220,369 +371,3 @@ fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
Ok(Model::new("New Model"))
}
}
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_json: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_json {
app.start_import_wizard(json);
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let is_cmd_mode = matches!(app.mode, AppMode::CommandMode { .. });
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
if is_cmd_mode {
draw_command_bar(f, main_chunks[3], app);
} else {
draw_status(f, main_chunks[3], app);
}
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget, size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard {
f.render_widget(ImportWizardWidget::new(wizard), size);
}
}
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
draw_export_prompt(f, size, app);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1], app);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let file = app
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let pad = " ".repeat((area.width as usize).saturating_sub(title.len() + right.len()));
let line = format!("{title}{pad}{right}");
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
if side_open {
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
chunks[0],
);
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
a,
);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
a,
);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
a,
);
}
} else {
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
area,
);
}
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode), area);
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let mode_badge = match &app.mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
};
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {mode_badge}{search_part} {msg}");
let right = view_badge;
let pad = " ".repeat((area.width as usize).saturating_sub(left.len() + right.len()));
let line = format!("{left}{pad}{right}");
let badge_style = match &app.mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
};
f.render_widget(Paragraph::new(line).style(badge_style), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::CommandMode { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let line = format!(":{buf}");
f.render_widget(
Paragraph::new(line).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_w = 64u16.min(area.width);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height / 2;
let popup_area = Rect::new(x, y, popup_w, 3);
f.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Export CSV — path (Esc cancel) ");
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
let w = 58u16.min(area.width.saturating_sub(4));
let h = 20u16.min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
let popup = Rect::new(x, y, w, h);
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.title(" Welcome to improvise ");
let inner = block.inner(popup);
f.render_widget(block, popup);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file.json> Import a JSON file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -48,6 +48,25 @@ impl Group {
}
}
/// What kind of category this is.
/// Regular categories store their items explicitly. Virtual categories
/// are synthesized at query time by the layout layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CategoryKind {
#[default]
Regular,
/// Items are "0", "1", ... N where N = number of matching cells.
VirtualIndex,
/// Items are the names of all regular categories + "Value".
VirtualDim,
}
impl CategoryKind {
pub fn is_virtual(&self) -> bool {
!matches!(self, CategoryKind::Regular)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Category {
pub id: CategoryId,
@ -58,6 +77,9 @@ pub struct Category {
pub groups: Vec<Group>,
/// Next item id counter
next_item_id: ItemId,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
impl Category {
@ -68,9 +90,15 @@ impl Category {
items: IndexMap::new(),
groups: Vec::new(),
next_item_id: 0,
kind: CategoryKind::Regular,
}
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
}
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
let name = name.into();
if let Some(item) = self.items.get(&name) {
@ -105,31 +133,10 @@ impl Category {
}
}
// pub fn item_by_name(&self, name: &str) -> Option<&Item> {
// self.items.get(name)
// }
// pub fn item_index(&self, name: &str) -> Option<usize> {
// self.items.get_index_of(name)
// }
/// Returns item names in order, grouped hierarchically
pub fn ordered_item_names(&self) -> Vec<&str> {
self.items.keys().map(|s| s.as_str()).collect()
}
/// Returns unique group names in insertion order, derived from item.group fields.
pub fn top_level_groups(&self) -> Vec<&str> {
let mut seen = Vec::new();
for item in self.items.values() {
if let Some(g) = &item.group {
if !seen.contains(&g.as_str()) {
seen.push(g.as_str());
}
}
}
seen
}
}
#[cfg(test)]
@ -185,30 +192,6 @@ mod tests {
assert_eq!(c.groups.len(), 1);
}
#[test]
fn top_level_groups_returns_unique_groups_in_insertion_order() {
let mut c = cat();
c.add_item_in_group("Jan", "Q1");
c.add_item_in_group("Feb", "Q1");
c.add_item_in_group("Apr", "Q2");
assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]);
}
#[test]
fn top_level_groups_empty_for_ungrouped_category() {
let mut c = cat();
c.add_item("East");
c.add_item("West");
assert!(c.top_level_groups().is_empty());
}
#[test]
fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() {
let mut c = cat();
c.add_group(Group::new("Orphan"));
assert!(c.top_level_groups().is_empty());
}
#[test]
fn item_index_reflects_insertion_order() {
let mut c = cat();

View File

@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use super::symbol::{Symbol, SymbolTable};
/// A cell key is a sorted vector of (category_name, item_name) pairs.
/// Sorted by category name for canonical form.
@ -41,6 +43,7 @@ impl CellKey {
)
}
#[allow(dead_code)]
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
partial
.iter()
@ -85,11 +88,22 @@ impl std::fmt::Display for CellValue {
}
}
/// Interned representation of a CellKey — cheap to hash and compare.
/// Sorted by first element (category Symbol) for canonical form.
#[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)]
pub struct DataStore {
cells: HashMap<CellKey, CellValue>,
/// Primary storage — interned keys for O(1) hash/compare.
cells: HashMap<InternedKey, CellValue>,
/// String interner — all category/item names are interned here.
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
}
impl Serialize for DataStore {
@ -97,7 +111,8 @@ impl Serialize for DataStore {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
for (k, v) in &self.cells {
seq.serialize_element(&(k, v))?;
let cell_key = self.to_cell_key(k);
seq.serialize_element(&(cell_key, v))?;
}
seq.end()
}
@ -106,8 +121,11 @@ impl Serialize for DataStore {
impl<'de> Deserialize<'de> for DataStore {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
Ok(DataStore { cells })
let mut store = DataStore::default();
for (key, value) in pairs {
store.set(key, value);
}
Ok(store)
}
}
@ -116,27 +134,145 @@ impl DataStore {
Self::default()
}
/// Intern a CellKey into an InternedKey.
pub fn intern_key(&mut self, key: &CellKey) -> InternedKey {
InternedKey(self.symbols.intern_coords(&key.0))
}
/// Convert an InternedKey back to a CellKey (string form).
pub fn to_cell_key(&self, ikey: &InternedKey) -> CellKey {
CellKey(
ikey.0
.iter()
.map(|(c, i)| {
(
self.symbols.resolve(*c).to_string(),
self.symbols.resolve(*i).to_string(),
)
})
.collect(),
)
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
self.cells.insert(key, value);
let ikey = self.intern_key(&key);
// Update index for each coordinate pair
for pair in &ikey.0 {
self.index.entry(*pair).or_default().insert(ikey.clone());
}
self.cells.insert(ikey, value);
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
self.cells.get(key)
let ikey = self.lookup_key(key)?;
self.cells.get(&ikey)
}
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
&self.cells
/// Look up an InternedKey for a CellKey without interning new symbols.
fn lookup_key(&self, key: &CellKey) -> Option<InternedKey> {
let pairs: Option<Vec<(Symbol, Symbol)>> = key
.0
.iter()
.map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
pairs.map(InternedKey)
}
/// 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))
}
pub fn remove(&mut self, key: &CellKey) {
self.cells.remove(key);
let Some(ikey) = self.lookup_key(key) else {
return;
};
if self.cells.remove(&ikey).is_some() {
for pair in &ikey.0 {
if let Some(set) = self.index.get_mut(pair) {
set.remove(&ikey);
}
}
}
}
/// All cells where partial coords match
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
self.cells
/// Values of all cells where every coordinate in `partial` matches.
/// Hot path: avoids allocating CellKey for each result.
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
if partial.is_empty() {
return self.cells.values().collect();
}
// Intern the partial key (lookup only, no new symbols)
let interned_partial: Vec<(Symbol, Symbol)> = partial
.iter()
.filter(|(key, _)| key.matches_partial(partial))
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| self.cells.get(ikey))
.collect()
}
/// All cells where every coordinate in `partial` matches.
/// Allocates CellKey strings for each match — use `matching_values`
/// if you only need values.
#[allow(dead_code)]
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(CellKey, &CellValue)> {
if partial.is_empty() {
return self.iter_cells().collect();
}
let interned_partial: Vec<(Symbol, Symbol)> = partial
.iter()
.filter_map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
if interned_partial.len() < partial.len() {
return vec![];
}
let mut sets: Vec<&HashSet<InternedKey>> = interned_partial
.iter()
.filter_map(|pair| self.index.get(pair))
.collect();
if sets.len() < interned_partial.len() {
return vec![];
}
sets.sort_by_key(|s| s.len());
let first = sets[0];
let rest = &sets[1..];
first
.iter()
.filter(|ikey| rest.iter().all(|s| s.contains(*ikey)))
.filter_map(|ikey| {
let value = self.cells.get(ikey)?;
Some((self.to_cell_key(ikey), value))
})
.collect()
}
}
@ -285,7 +421,7 @@ mod data_store {
let k = key(&[("Region", "East")]);
store.set(k.clone(), CellValue::Number(5.0));
store.remove(&k);
assert!(store.cells().is_empty());
assert!(store.iter_cells().next().is_none());
}
#[test]

View File

@ -1,5 +1,6 @@
pub mod category;
pub mod cell;
pub mod model;
pub mod symbol;
pub mod types;
pub use model::Model;
pub use types::Model;

79
src/model/symbol.rs Normal file
View File

@ -0,0 +1,79 @@
use std::collections::HashMap;
/// An interned string identifier. Copy-cheap, O(1) hash and equality.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Symbol(u64);
/// Bidirectional string ↔ Symbol mapping.
#[derive(Debug, Clone, Default)]
pub struct SymbolTable {
to_id: HashMap<String, Symbol>,
to_str: Vec<String>,
}
impl SymbolTable {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
/// Intern a string, returning its Symbol. Returns existing Symbol if
/// already interned.
pub fn intern(&mut self, s: &str) -> Symbol {
if let Some(&id) = self.to_id.get(s) {
return id;
}
let id = Symbol(self.to_str.len() as u64);
self.to_str.push(s.to_string());
self.to_id.insert(s.to_string(), id);
id
}
/// Look up the Symbol for a string without interning.
pub fn get(&self, s: &str) -> Option<Symbol> {
self.to_id.get(s).copied()
}
/// Resolve a Symbol back to its string.
pub fn resolve(&self, sym: Symbol) -> &str {
&self.to_str[sym.0 as usize]
}
/// Intern a (category, item) pair.
pub fn intern_pair(&mut self, cat: &str, item: &str) -> (Symbol, Symbol) {
(self.intern(cat), self.intern(item))
}
/// Intern a full coordinate list.
pub fn intern_coords(&mut self, coords: &[(String, String)]) -> Vec<(Symbol, Symbol)> {
coords.iter().map(|(c, i)| self.intern_pair(c, i)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intern_returns_same_id() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("hello");
assert_eq!(a, b);
}
#[test]
fn different_strings_different_ids() {
let mut t = SymbolTable::new();
let a = t.intern("hello");
let b = t.intern("world");
assert_ne!(a, b);
}
#[test]
fn resolve_roundtrips() {
let mut t = SymbolTable::new();
let s = t.intern("test");
assert_eq!(t.resolve(s), "test");
}
}

View File

@ -1,10 +1,12 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::category::{Category, CategoryId};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::Formula;
use crate::formula::{AggFunc, Formula};
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
@ -18,28 +20,56 @@ pub struct Model {
pub views: IndexMap<String, View>,
pub active_view: String,
next_category_id: CategoryId,
/// Per-measure aggregation function (measure item name → agg func).
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
#[serde(default)]
pub measure_agg: HashMap<String, AggFunc>,
}
impl Model {
pub fn new(name: impl Into<String>) -> Self {
use crate::model::category::CategoryKind;
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
Self {
let mut categories = IndexMap::new();
// Virtual categories — always present, default to Axis::None
categories.insert(
"_Index".to_string(),
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
);
categories.insert(
"_Dim".to_string(),
Category::new(1, "_Dim").with_kind(CategoryKind::VirtualDim),
);
let mut m = Self {
name,
categories: IndexMap::new(),
categories,
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 0,
next_category_id: 2,
measure_agg: HashMap::new(),
};
// Add virtuals to existing views (default view)
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
}
m
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
if self.categories.len() >= MAX_CATEGORIES {
// Virtuals don't count against the regular category limit
let regular_count = self
.categories
.values()
.filter(|c| !c.kind.is_virtual())
.count();
if regular_count >= MAX_CATEGORIES {
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
}
if self.categories.contains_key(&name) {
@ -150,6 +180,7 @@ impl Model {
}
/// Return all category names
/// Names of all categories (including virtual ones).
pub fn category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
}
@ -172,6 +203,59 @@ impl Model {
self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0)
}
/// Evaluate a cell, aggregating over any hidden (None-axis) categories.
/// When `none_cats` is empty, delegates to `evaluate`.
/// Otherwise, uses `matching_cells` with the partial key and aggregates
/// using the measure's agg function (default SUM).
pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
if none_cats.is_empty() {
return self.evaluate(key);
}
// Check formulas first — they handle their own aggregation
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target {
return self.eval_formula(formula, key);
}
}
}
// Aggregate raw data across all None-axis categories
let values: Vec<f64> = self
.data
.matching_values(&key.0)
.into_iter()
.filter_map(|v| v.as_f64())
.collect();
if values.is_empty() {
return None;
}
// Determine agg func from measure_agg map, defaulting to SUM
let agg = key
.get("Measure")
.and_then(|m| self.measure_agg.get(m))
.unwrap_or(&AggFunc::Sum);
let result = match agg {
AggFunc::Sum => values.iter().sum(),
AggFunc::Avg => values.iter().sum::<f64>() / values.len() as f64,
AggFunc::Min => values.iter().cloned().reduce(f64::min)?,
AggFunc::Max => values.iter().cloned().reduce(f64::max)?,
AggFunc::Count => values.len() as f64,
};
Some(CellValue::Number(result))
}
/// Evaluate aggregated as f64, returning 0.0 for empty cells.
pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 {
self.evaluate_aggregated(key, none_cats)
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
}
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
use crate::formula::{AggFunc, Expr};
@ -243,9 +327,9 @@ impl Model {
}
let values: Vec<f64> = model
.data
.matching_cells(&partial.0)
.matching_values(&partial.0)
.into_iter()
.filter_map(|(_, v)| v.as_f64())
.filter_map(|v| v.as_f64())
.collect();
match func {
AggFunc::Sum => Some(values.iter().sum()),
@ -339,7 +423,8 @@ mod model_tests {
let id1 = m.add_category("Region").unwrap();
let id2 = m.add_category("Region").unwrap();
assert_eq!(id1, id2);
assert_eq!(m.categories.len(), 1);
// Region + 2 virtuals (_Index, _Dim)
assert_eq!(m.category_names().len(), 3);
}
#[test]
@ -490,6 +575,79 @@ mod model_tests {
m.set_cell(k.clone(), CellValue::Number(77.0));
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
}
#[test]
fn evaluate_aggregated_sums_over_hidden_dimension() {
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("Jan-01");
m.category_mut("Date").unwrap().add_item("Jan-02");
m.category_mut("Measure").unwrap().add_item("Amount");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]),
CellValue::Number(100.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]),
CellValue::Number(50.0),
);
// Without hidden dims, returns None for partial key
let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]);
assert_eq!(m.evaluate(&partial_key), None);
// With Date as hidden dimension, aggregates to SUM
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&partial_key, &none_cats);
assert_eq!(result, Some(CellValue::Number(150.0)));
}
#[test]
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
m.category_mut("Region").unwrap().add_item("East");
m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0));
let key = coord(&[("Region", "East")]);
assert_eq!(
m.evaluate_aggregated(&key, &[]),
Some(CellValue::Number(42.0))
);
}
#[test]
fn evaluate_aggregated_respects_measure_agg() {
use crate::formula::AggFunc;
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("D1");
m.category_mut("Date").unwrap().add_item("D2");
m.category_mut("Measure").unwrap().add_item("Price");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]),
CellValue::Number(10.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]),
CellValue::Number(30.0),
);
m.measure_agg.insert("Price".to_string(), AggFunc::Avg);
let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]);
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&key, &none_cats);
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
}
}
#[cfg(test)]
@ -1234,12 +1392,14 @@ mod five_category {
#[test]
fn five_categories_well_within_limit() {
let m = build_model();
assert_eq!(m.categories.len(), 5);
// 5 regular + 2 virtual (_Index, _Dim)
assert_eq!(m.category_names().len(), 7);
let mut m2 = build_model();
for i in 0..7 {
m2.add_category(format!("Extra{i}")).unwrap();
}
assert_eq!(m2.categories.len(), 12);
// 12 regular + 2 virtuals = 14
assert_eq!(m2.category_names().len(), 14);
assert!(m2.add_category("OneMore").is_err());
}
}

View File

@ -88,7 +88,7 @@ pub fn format_md(model: &Model) -> String {
}
// Data — sorted by coordinate string for deterministic diffs
let mut cells: Vec<_> = model.data.cells().iter().collect();
let mut cells: Vec<_> = model.data.iter_cells().collect();
cells.sort_by_key(|(k, _)| coord_str(k));
if !cells.is_empty() {
writeln!(out, "\n## Data").unwrap();
@ -97,7 +97,7 @@ pub fn format_md(model: &Model) -> String {
CellValue::Number(_) => value.to_string(),
CellValue::Text(s) => format!("\"{}\"", s),
};
writeln!(out, "{} = {}", coord_str(key), val_str).unwrap();
writeln!(out, "{} = {}", coord_str(&key), val_str).unwrap();
}
}
@ -117,6 +117,7 @@ pub fn format_md(model: &Model) -> String {
Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(),
None => writeln!(out, "{}: page", cat).unwrap(),
},
Axis::None => writeln!(out, "{}: none", cat).unwrap(),
}
}
if !view.number_format.is_empty() {
@ -315,6 +316,7 @@ pub fn parse_md(text: &str) -> Result<Model> {
let axis = match rest {
"row" => Axis::Row,
"column" => Axis::Column,
"none" => Axis::None,
_ => continue,
};
view.axes.push((cat.to_string(), axis));
@ -457,11 +459,15 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
}
let row_values: Vec<String> = (0..layout.col_count())
.map(|ci| {
layout
.cell_key(ri, ci)
.and_then(|key| model.evaluate(&key))
.map(|v| v.to_string())
.unwrap_or_default()
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();
out.push_str(&row_values.join(","));

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
Axis::Row => ("Row ↕", Color::Green),
Axis::Column => ("Col ↔", Color::Blue),
Axis::Page => ("Page ☰", Color::Magenta),
Axis::None => ("None ∅", Color::DarkGray),
}
}

817
src/ui/effect.rs Normal file
View File

@ -0,0 +1,817 @@
use std::fmt::Debug;
use std::path::PathBuf;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use super::app::{App, AppMode};
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
}
// ── Model mutations ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct AddCategory(pub String);
impl Effect for AddCategory {
fn apply(&self, app: &mut App) {
let _ = app.model.add_category(&self.0);
}
}
#[derive(Debug)]
pub struct AddItem {
pub category: String,
pub item: String,
}
impl Effect for AddItem {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item(&self.item);
}
}
}
#[derive(Debug)]
pub struct AddItemInGroup {
pub category: String,
pub item: String,
pub group: String,
}
impl Effect for AddItemInGroup {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group);
}
}
}
#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
fn apply(&self, app: &mut App) {
app.model.set_cell(self.0.clone(), self.1.clone());
}
}
#[derive(Debug)]
pub struct ClearCell(pub CellKey);
impl Effect for ClearCell {
fn apply(&self, app: &mut App) {
app.model.clear_cell(&self.0);
}
}
#[derive(Debug)]
pub struct AddFormula {
pub raw: String,
pub target_category: String,
}
impl Effect for AddFormula {
fn apply(&self, app: &mut App) {
if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) {
app.model.add_formula(formula);
}
}
}
#[derive(Debug)]
pub struct RemoveFormula {
pub target: String,
pub target_category: String,
}
impl Effect for RemoveFormula {
fn apply(&self, app: &mut App) {
app.model
.remove_formula(&self.target, &self.target_category);
}
}
// ── View mutations ───────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct CreateView(pub String);
impl Effect for CreateView {
fn apply(&self, app: &mut App) {
app.model.create_view(&self.0);
}
}
#[derive(Debug)]
pub struct DeleteView(pub String);
impl Effect for DeleteView {
fn apply(&self, app: &mut App) {
let _ = app.model.delete_view(&self.0);
}
}
#[derive(Debug)]
pub struct SwitchView(pub String);
impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
}
}
/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
}
}
}
/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
}
}
}
#[derive(Debug)]
pub struct SetAxis {
pub category: String,
pub axis: Axis,
}
impl Effect for SetAxis {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_axis(&self.category, self.axis);
}
}
#[derive(Debug)]
pub struct SetPageSelection {
pub category: String,
pub item: String,
}
impl Effect for SetPageSelection {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_page_selection(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ToggleGroup {
pub category: String,
pub group: String,
}
impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.toggle_group_collapse(&self.category, &self.group);
}
}
#[derive(Debug)]
pub struct HideItem {
pub category: String,
pub item: String,
}
impl Effect for HideItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.hide_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ShowItem {
pub category: String,
pub item: String,
}
impl Effect for ShowItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.show_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct TransposeAxes;
impl Effect for TransposeAxes {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().transpose_axes();
}
}
#[derive(Debug)]
pub struct CycleAxis(pub String);
impl Effect for CycleAxis {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().cycle_axis(&self.0);
}
}
#[derive(Debug)]
pub struct SetNumberFormat(pub String);
impl Effect for SetNumberFormat {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().number_format = self.0.clone();
}
}
// ── Navigation ───────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct SetSelected(pub usize, pub usize);
impl Effect for SetSelected {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().selected = (self.0, self.1);
}
}
#[derive(Debug)]
pub struct SetRowOffset(pub usize);
impl Effect for SetRowOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().row_offset = self.0;
}
}
#[derive(Debug)]
pub struct SetColOffset(pub usize);
impl Effect for SetColOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().col_offset = self.0;
}
}
// ── App state ────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
fn apply(&self, app: &mut App) {
app.mode = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetStatus(pub String);
impl Effect for SetStatus {
fn apply(&self, app: &mut App) {
app.status_msg = self.0.clone();
}
}
#[derive(Debug)]
pub struct MarkDirty;
impl Effect for MarkDirty {
fn apply(&self, app: &mut App) {
app.dirty = true;
}
}
#[derive(Debug)]
pub struct SetYanked(pub Option<CellValue>);
impl Effect for SetYanked {
fn apply(&self, app: &mut App) {
app.yanked = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchQuery(pub String);
impl Effect for SetSearchQuery {
fn apply(&self, app: &mut App) {
app.search_query = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchMode(pub bool);
impl Effect for SetSearchMode {
fn apply(&self, app: &mut App) {
app.search_mode = self.0;
}
}
/// Set a named buffer's contents.
#[derive(Debug)]
pub struct SetBuffer {
pub name: String,
pub value: String,
}
impl Effect for SetBuffer {
fn apply(&self, app: &mut App) {
// "search" is special — it writes to search_query for backward compat
if self.name == "search" {
app.search_query = self.value.clone();
} else {
app.buffers.insert(self.name.clone(), self.value.clone());
}
}
}
#[derive(Debug)]
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
fn apply(&self, app: &mut App) {
app.tile_cat_idx = self.0;
}
}
/// Populate the drill state with a frozen snapshot of records.
/// Clears any previous drill state.
#[derive(Debug)]
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(),
pending_edits: std::collections::HashMap::new(),
});
}
}
/// Apply any pending edits to the model and clear the drill state.
#[derive(Debug)]
pub struct ApplyAndClearDrill;
impl Effect for ApplyAndClearDrill {
fn apply(&self, app: &mut App) {
let Some(drill) = app.drill_state.take() else {
return;
};
// For each pending edit, update the cell
for ((record_idx, col_name), new_value) in &drill.pending_edits {
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
continue;
};
if col_name == "Value" {
// Update the cell's value
let value = if new_value.is_empty() {
app.model.clear_cell(orig_key);
continue;
} else if let Ok(n) = new_value.parse::<f64>() {
CellValue::Number(n)
} else {
CellValue::Text(new_value.clone())
};
app.model.set_cell(orig_key.clone(), value);
} else {
// Rename a coordinate: remove old cell, insert new with updated coord
let value = match app.model.get_cell(orig_key) {
Some(v) => v.clone(),
None => continue,
};
app.model.clear_cell(orig_key);
// Build new key by replacing the coord
let new_coords: Vec<(String, String)> = orig_key
.0
.iter()
.map(|(c, i)| {
if c == col_name {
(c.clone(), new_value.clone())
} else {
(c.clone(), i.clone())
}
})
.collect();
let new_key = CellKey::new(new_coords);
// Ensure the new item exists in that category
if let Some(cat) = app.model.category_mut(col_name) {
cat.add_item(new_value.clone());
}
app.model.set_cell(new_key, value);
}
}
app.dirty = true;
}
}
/// Stage a pending edit in the drill state.
#[derive(Debug)]
pub struct SetDrillPendingEdit {
pub record_idx: usize,
pub col_name: String,
pub new_value: String,
}
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());
}
}
}
// ── Side effects ─────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct Save;
impl Effect for Save {
fn apply(&self, app: &mut App) {
if let Some(ref path) = app.file_path {
match crate::persistence::save(&app.model, path) {
Ok(()) => {
app.dirty = false;
app.status_msg = format!("Saved to {}", path.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
} else {
app.status_msg = "No file path — use :w <path>".to_string();
}
}
}
#[derive(Debug)]
pub struct SaveAs(pub PathBuf);
impl Effect for SaveAs {
fn apply(&self, app: &mut App) {
match crate::persistence::save(&app.model, &self.0) {
Ok(()) => {
app.file_path = Some(self.0.clone());
app.dirty = false;
app.status_msg = format!("Saved to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
}
}
/// Dispatch a key event to the import wizard.
/// The wizard has its own internal state machine; this effect handles
/// all wizard key interactions and App-level side effects.
#[derive(Debug)]
pub struct WizardKey {
pub key_code: crossterm::event::KeyCode,
}
impl Effect for WizardKey {
fn apply(&self, app: &mut App) {
use crate::import::wizard::WizardStep;
let Some(wizard) = &mut app.wizard else {
return;
};
match &wizard.step.clone() {
WizardStep::Preview => match self.key_code {
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
wizard.advance()
}
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::SelectArrayPath => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ReviewProposals => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ConfigureDates => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::DefineFormulas => {
if wizard.formula_editing {
match self.key_code {
crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
_ => {}
}
} else {
match self.key_code {
crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
}
}
}
WizardStep::NameModel => match self.key_code {
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
crossterm::event::KeyCode::Enter => match wizard.build_model() {
Ok(mut model) => {
model.normalize_view_state();
app.model = model;
app.formula_cursor = 0;
app.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.mode = AppMode::Normal;
app.wizard = None;
}
Err(e) => {
if let Some(w) = &mut app.wizard {
w.message = Some(format!("Error: {e}"));
}
}
},
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::Done => {
app.mode = AppMode::Normal;
app.wizard = None;
}
}
}
}
/// Start the import wizard from a JSON file path.
#[derive(Debug)]
pub struct StartImportWizard(pub String);
impl Effect for StartImportWizard {
fn apply(&self, app: &mut App) {
match std::fs::read_to_string(&self.0) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.mode = AppMode::ImportWizard;
}
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
}
},
Err(e) => {
app.status_msg = format!("Cannot read file: {e}");
}
}
}
}
#[derive(Debug)]
pub struct ExportCsv(pub PathBuf);
impl Effect for ExportCsv {
fn apply(&self, app: &mut App) {
let view_name = app.model.active_view.clone();
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
Ok(()) => {
app.status_msg = format!("Exported to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Export error: {e}");
}
}
}
}
/// Load a model from a file, replacing the current one.
#[derive(Debug)]
pub struct LoadModel(pub PathBuf);
impl Effect for LoadModel {
fn apply(&self, app: &mut App) {
match crate::persistence::load(&self.0) {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Load error: {e}");
}
}
}
}
/// Headless JSON/CSV import: read file, analyze, build model, replace current.
#[derive(Debug)]
pub struct ImportJsonHeadless {
pub path: PathBuf,
pub model_name: Option<String>,
pub array_path: Option<String>,
}
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
};
use crate::import::wizard::ImportPipeline;
let is_csv = self
.path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
let records = if is_csv {
match crate::import::csv_parser::parse_csv(&self.path) {
Ok(recs) => recs,
Err(e) => {
app.status_msg = format!("CSV error: {e}");
return;
}
}
} else {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) => {
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
return;
}
};
if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => {
app.status_msg = format!("No array at path '{ap}'");
return;
}
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
let paths = find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => {
app.status_msg = "Could not extract records array".to_string();
return;
}
}
} else {
app.status_msg = "No array found in JSON".to_string();
return;
}
}
};
let proposals = analyze_records(&records);
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = ImportPipeline {
raw,
array_paths: vec![],
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
records,
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: self
.model_name
.as_deref()
.unwrap_or("Imported Model")
.to_string(),
formulas: vec![],
};
match pipeline.build_model() {
Ok(new_model) => {
app.model = new_model;
app.status_msg = "Imported successfully".to_string();
}
Err(e) => {
app.status_msg = format!("Import error: {e}");
}
}
}
}
#[derive(Debug)]
pub struct SetPanelOpen {
pub panel: Panel,
pub open: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum Panel {
Formula,
Category,
View,
}
impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_panel_open = self.open,
Panel::Category => app.category_panel_open = self.open,
Panel::View => app.view_panel_open = self.open,
}
}
}
#[derive(Debug)]
pub struct SetPanelCursor {
pub panel: Panel,
pub cursor: usize,
}
impl Effect for SetPanelCursor {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_cursor = self.cursor,
Panel::Category => app.cat_panel_cursor = self.cursor,
Panel::View => app.view_panel_cursor = self.cursor,
}
}
}
// ── Convenience constructors ─────────────────────────────────────────────────
pub fn mark_dirty() -> Box<dyn Effect> {
Box::new(MarkDirty)
}
pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
Box::new(SetStatus(msg.into()))
}
pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
Box::new(ChangeMode(mode))
}
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
Box::new(SetSelected(row, col))
}

View File

@ -13,6 +13,10 @@ use crate::view::{AxisEntry, GridLayout};
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const MIN_COL_WIDTH: u16 = 6;
const MAX_COL_WIDTH: u16 = 32;
/// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const GROUP_EXPANDED: &str = "";
const GROUP_COLLAPSED: &str = "";
@ -20,21 +24,44 @@ pub struct GridWidget<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>,
pub drill_state: Option<&'a crate::ui::app::DrillState>,
}
impl<'a> GridWidget<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
pub fn new(
model: &'a Model,
mode: &'a AppMode,
search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>,
drill_state: Option<&'a crate::ui::app::DrillState>,
) -> Self {
Self {
model,
mode,
search_query,
buffers,
drill_state,
}
}
/// 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 layout = GridLayout::new(self.model, view);
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 row_offset = view.row_offset;
let col_offset = view.col_offset;
@ -43,6 +70,37 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
// Per-column widths. In records mode, size each column to its widest
// content (pending edit → record value → header label). Otherwise use
// the fixed COL_WIDTH. Always at least MIN_COL_WIDTH, capped at MAX.
let col_widths: Vec<u16> = if layout.is_records_mode() {
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
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
let sub_widths: Vec<u16> = (0..n_row_levels)
@ -79,23 +137,39 @@ impl<'a> GridWidget<'a> {
})
.collect();
// Map each data-col index to its group name (None if ungrouped)
let col_groups: Vec<Option<String>> = {
let mut groups = Vec::new();
let mut current: Option<String> = None;
for entry in &layout.col_items {
match entry {
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
AxisEntry::DataItem(_) => groups.push(current.clone()),
}
}
groups
};
let has_col_groups = col_groups.iter().any(|g| g.is_some());
let has_col_groups = layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_range =
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
// Compute how many columns fit starting from col_offset.
let data_area_width = area.width.saturating_sub(ROW_HEADER_WIDTH);
let mut acc = 0u16;
let mut last = col_offset;
for ci in col_offset..layout.col_count() {
let w = *col_widths.get(ci).unwrap_or(&COL_WIDTH);
if acc + w > data_area_width {
break;
}
acc += w;
last = ci + 1;
}
let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count());
// x offset (relative to the data area start) for each column index.
let col_x: Vec<u16> = {
let mut v = vec![0u16; layout.col_count() + 1];
for ci in 0..layout.col_count() {
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&COL_WIDTH);
}
v
};
let col_x_at = |ci: usize| -> u16 {
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(&COL_WIDTH) };
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
@ -116,30 +190,37 @@ impl<'a> GridWidget<'a> {
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut x = area.x + ROW_HEADER_WIDTH;
let mut prev_group: Option<&str> = None;
let mut prev_group: Option<String> = None;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let group = col_groups[ci].as_deref();
let label = if group != prev_group {
group.unwrap_or("")
let cw = col_w_at(ci) as usize;
let col_group = layout.col_group_for(ci);
let group_name = col_group.as_ref().map(|(_, g)| g.clone());
let label = if group_name != prev_group {
match &col_group {
Some((cat, g)) => {
let indicator = if view.is_group_collapsed(cat, g) {
GROUP_COLLAPSED
} else {
GROUP_EXPANDED
};
format!("{indicator} {g}")
}
None => String::new(),
}
} else {
""
String::new()
};
prev_group = group;
prev_group = group_name;
buf.set_string(
x,
y,
format!(
"{:<width$}",
truncate(label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
format!("{:<width$}", truncate(&label, cw), width = cw),
group_style,
);
x += COL_WIDTH;
}
y += 1;
}
@ -155,8 +236,12 @@ impl<'a> GridWidget<'a> {
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let label = if layout.col_cats.is_empty() {
layout.col_label(ci)
} else {
@ -175,17 +260,9 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!(
"{:>width$}",
truncate(&label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
format!("{:>width$}", truncate(&label, cw), width = cw),
styled,
);
x += COL_WIDTH;
if x >= area.x + area.width {
break;
}
}
y += 1;
}
@ -229,29 +306,47 @@ impl<'a> GridWidget<'a> {
),
group_header_style,
);
let mut x = area.x + ROW_HEADER_WIDTH;
while x < area.x + area.width {
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
buf.set_string(
x,
y,
format!("{:─<width$}", "", width = COL_WIDTH as usize),
format!("{:─<width$}", "", width = cw),
Style::default().fg(Color::DarkGray),
);
x += COL_WIDTH;
}
}
AxisEntry::DataItem(_) => {
let ri = data_row_idx;
data_row_idx += 1;
let row_style = if ri == sel_row {
let is_sel_row = ri == sel_row;
let row_style = if is_sel_row {
Style::default()
.fg(Color::Cyan)
.bg(ROW_HIGHLIGHT_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Paint row-highlight background across the entire row
// (data area + any trailing space) so gaps between columns
// and the margin after the last column share the highlight.
if is_sel_row {
let row_w = (area.x + area.width).saturating_sub(area.x);
buf.set_string(
area.x + ROW_HEADER_WIDTH,
y,
" ".repeat(row_w.saturating_sub(ROW_HEADER_WIDTH) as usize),
Style::default().bg(ROW_HIGHLIGHT_BG),
);
}
// Multi-level row header — one sub-column per row category
let mut hx = area.x;
for d in 0..n_row_levels {
@ -276,22 +371,31 @@ impl<'a> GridWidget<'a> {
hx += sub_widths[d];
}
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => {
x += COL_WIDTH;
continue;
}
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()))
} 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)
};
let value = self.model.evaluate(&key);
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str
@ -305,6 +409,13 @@ impl<'a> GridWidget<'a> {
.add_modifier(Modifier::BOLD)
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row {
let fg = if value.is_none() {
Color::DarkGray
} else {
Color::White
};
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if value.is_none() {
Style::default().fg(Color::DarkGray)
} else {
@ -314,29 +425,21 @@ impl<'a> GridWidget<'a> {
buf.set_string(
x,
y,
format!(
"{:>width$}",
truncate(&cell_str, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
format!("{:>width$}", truncate(&cell_str, cw), width = cw),
cell_style,
);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
if let AppMode::Editing { buffer } = self.mode {
let edit_x = area.x
+ ROW_HEADER_WIDTH
+ (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
{
let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
let edit_x = col_x_at(sel_col);
let cw = col_w_at(sel_col) as usize;
buf.set_string(
edit_x,
y,
truncate(
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
COL_WIDTH as usize,
),
truncate(&format!("{:<width$}", buffer, width = cw), cw),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::UNDERLINED),
@ -348,8 +451,8 @@ impl<'a> GridWidget<'a> {
y += 1;
}
// Total row
if layout.row_count() > 0 && layout.col_count() > 0 {
// Total row — numeric aggregation, only meaningful in pivot mode.
if !layout.is_records_mode() && layout.row_count() > 0 && layout.col_count() > 0 {
if y < area.y + area.height {
buf.set_string(
area.x,
@ -369,29 +472,25 @@ impl<'a> GridWidget<'a> {
.add_modifier(Modifier::BOLD),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| self.model.evaluate_f64(&key))
.map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
.sum();
let total_str = format_f64(total, fmt_comma, fmt_decimals);
buf.set_string(
x,
y,
format!(
"{:>width$}",
truncate(&total_str, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
format!("{:>width$}", truncate(&total_str, cw), width = cw),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
x += COL_WIDTH;
}
}
}
@ -520,7 +619,8 @@ mod tests {
fn render(model: &Model, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf);
let bufs = std::collections::HashMap::new();
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
buf
}

View File

@ -5,7 +5,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::analyzer::FieldKind;
use crate::import::analyzer::{DateComponent, FieldKind};
use crate::import::wizard::{ImportWizard, WizardStep};
pub struct ImportWizardWidget<'a> {
@ -29,10 +29,12 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Clear.render(popup_area, buf);
let title = match self.wizard.step {
WizardStep::Preview => " Import Wizard — Step 1: Preview ",
WizardStep::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardStep::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardStep::NameModel => " Import Wizard — Step 4: Name Model ",
WizardStep::Preview => " Import Wizard — Preview ",
WizardStep::SelectArrayPath => " Import Wizard — Select Array ",
WizardStep::ReviewProposals => " Import Wizard — Review Fields ",
WizardStep::ConfigureDates => " Import Wizard — Date Components ",
WizardStep::DefineFormulas => " Import Wizard — Formulas ",
WizardStep::NameModel => " Import Wizard — Name Model ",
WizardStep::Done => " Import Wizard — Done ",
};
@ -158,6 +160,152 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Style::default().fg(Color::DarkGray),
);
}
WizardStep::ConfigureDates => {
buf.set_string(
x,
y,
"Select date components to extract (Space toggle):",
Style::default().fg(Color::Yellow),
);
y += 1;
let tc_proposals = self.wizard.time_category_proposals();
let mut item_idx = 0;
for proposal in &tc_proposals {
if y >= inner.y + inner.height - 2 {
break;
}
let fmt_str = proposal.date_format.as_deref().unwrap_or("?");
let header = format!(" {} (format: {})", proposal.field, fmt_str);
buf.set_string(
x,
y,
truncate(&header, w),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
y += 1;
for component in &[
DateComponent::Year,
DateComponent::Month,
DateComponent::Quarter,
] {
if y >= inner.y + inner.height - 2 {
break;
}
let enabled = proposal.date_components.contains(component);
let check = if enabled { "[\u{2713}]" } else { "[ ]" };
let label = match component {
DateComponent::Year => "Year",
DateComponent::Month => "Month",
DateComponent::Quarter => "Quarter",
};
let row = format!(" {} {}", check, label);
let is_sel = item_idx == self.wizard.cursor;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if enabled {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(x, y, truncate(&row, w), style);
y += 1;
item_idx += 1;
}
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(
x,
hint_y,
"Space: toggle Enter: next Esc: cancel",
Style::default().fg(Color::DarkGray),
);
}
WizardStep::DefineFormulas => {
buf.set_string(
x,
y,
"Define formulas (optional):",
Style::default().fg(Color::Yellow),
);
y += 1;
// Show existing formulas
if self.wizard.pipeline.formulas.is_empty() && !self.wizard.formula_editing {
buf.set_string(
x,
y,
" (no formulas yet)",
Style::default().fg(Color::DarkGray),
);
y += 1;
}
for (i, formula) in self.wizard.pipeline.formulas.iter().enumerate() {
if y >= inner.y + inner.height - 5 {
break;
}
let is_sel = i == self.wizard.cursor && !self.wizard.formula_editing;
let style = if is_sel {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
buf.set_string(x, y, truncate(&format!(" {}", formula), w), style);
y += 1;
}
// Formula input area
if self.wizard.formula_editing {
y += 1;
buf.set_string(
x,
y,
"Formula (e.g., Profit = Revenue - Cost):",
Style::default().fg(Color::Yellow),
);
y += 1;
let input = format!("> {}\u{2588}", self.wizard.formula_buffer);
buf.set_string(x, y, truncate(&input, w), Style::default().fg(Color::Green));
y += 1;
}
// Sample formulas
let samples = self.wizard.sample_formulas();
if !samples.is_empty() {
y += 1;
buf.set_string(x, y, "Examples:", Style::default().fg(Color::DarkGray));
y += 1;
for sample in &samples {
if y >= inner.y + inner.height - 1 {
break;
}
buf.set_string(
x,
y,
truncate(&format!(" {}", sample), w),
Style::default().fg(Color::DarkGray),
);
y += 1;
}
}
let hint_y = inner.y + inner.height - 1;
let hint = if self.wizard.formula_editing {
"Enter: add Esc: cancel"
} else {
"n: new formula d: delete Enter: next Esc: cancel"
};
buf.set_string(x, hint_y, hint, Style::default().fg(Color::DarkGray));
}
WizardStep::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;

View File

@ -1,5 +1,6 @@
pub mod app;
pub mod category_panel;
pub mod effect;
pub mod formula_panel;
pub mod grid;
pub mod help;

View File

@ -14,17 +14,23 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
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,
pub tile_cat_idx: usize,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
Self {
model,
mode,
tile_cat_idx,
}
}
}
@ -32,8 +38,8 @@ impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view();
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
Some(self.tile_cat_idx)
} else {
None
};
@ -65,7 +71,7 @@ impl<'a> Widget for TileBar<'a> {
}
// Hint
if matches!(self.mode, AppMode::TileSelect { .. }) {
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 {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));

View File

@ -6,6 +6,7 @@ pub enum Axis {
Row,
Column,
Page,
None,
}
impl std::fmt::Display for Axis {
@ -14,6 +15,7 @@ impl std::fmt::Display for Axis {
Axis::Row => write!(f, "Row ↕"),
Axis::Column => write!(f, "Col ↔"),
Axis::Page => write!(f, "Page ☰"),
Axis::None => write!(f, "None ∅"),
}
}
}

View File

@ -1,4 +1,4 @@
use crate::model::cell::CellKey;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::{Axis, View};
@ -27,9 +27,35 @@ pub struct GridLayout {
pub page_coords: Vec<(String, String)>,
pub row_items: Vec<AxisEntry>,
pub col_items: Vec<AxisEntry>,
/// 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)>>,
}
impl GridLayout {
/// Build a layout. When records-mode is active and `frozen_records`
/// is provided, use that snapshot instead of re-querying the store.
pub fn with_frozen_records(
model: &Model,
view: &View,
frozen_records: Option<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();
layout.row_items = row_items;
layout.records = Some(records);
}
}
layout
}
pub fn new(model: &Model, view: &View) -> Self {
let row_cats: Vec<String> = view
.categories_on(Axis::Row)
@ -46,6 +72,11 @@ impl GridLayout {
.into_iter()
.map(String::from)
.collect();
let none_cats: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
let page_coords = page_cats
.iter()
@ -68,18 +99,107 @@ impl GridLayout {
})
.collect();
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
// Detect records mode: _Index on Row and _Dim on Col
let is_records_mode =
row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");
if is_records_mode {
Self::build_records_mode(model, view, page_coords, none_cats)
} else {
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
Self {
row_cats,
col_cats,
page_coords,
row_items,
col_items,
none_cats,
records: None,
}
}
}
/// Build a records-mode layout: rows are individual cells, columns are
/// category names + "Value". Cells matching the page filter are enumerated.
fn build_records_mode(
model: &Model,
_view: &View,
page_coords: Vec<(String, String)>,
none_cats: Vec<String>,
) -> Self {
// Filter cells by page_coords
let partial: Vec<(String, String)> = page_coords.clone();
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
model
.data
.matching_cells(&partial)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
// Sort for deterministic ordering
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())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
// Synthesize col items: one per category + "Value"
let cat_names: Vec<String> = model
.category_names()
.into_iter()
.map(String::from)
.collect();
let mut col_items: Vec<AxisEntry> = cat_names
.iter()
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
.collect();
col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));
Self {
row_cats,
col_cats,
row_cats: vec!["_Index".to_string()],
col_cats: vec!["_Dim".to_string()],
page_coords,
row_items,
col_items,
none_cats,
records: Some(records),
}
}
/// Get the display string for the cell at (row, col) in records mode.
/// Returns None for normal (non-records) layouts.
pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
let records = self.records.as_ref()?;
let record = records.get(row)?;
let col_item = self.col_label(col);
if col_item == "Value" {
Some(record.1.to_string())
} else {
// col_item is a category name
let found = record
.0
.0
.iter()
.find(|(c, _)| c == &col_item)
.map(|(_, v)| v.clone());
Some(found.unwrap_or_default())
}
}
/// Whether this layout is in records mode.
pub fn is_records_mode(&self) -> bool {
self.records.is_some()
}
/// Number of data rows (group headers excluded).
pub fn row_count(&self) -> usize {
self.row_items
@ -128,7 +248,17 @@ impl GridLayout {
/// 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).
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 {
return None;
}
}
let row_item = self
.row_items
.iter()
@ -188,6 +318,40 @@ impl GridLayout {
}
None
}
/// Find the group containing the Nth data row.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
let vi = self.data_row_to_visual(data_row)?;
self.row_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
/// Find the group containing the Nth data column.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
let vi = self.data_col_to_visual(data_col)?;
self.col_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
}
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
@ -223,7 +387,7 @@ fn expand_category(
}
// Skip the data item if its group is collapsed.
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
continue;
}
@ -260,6 +424,79 @@ mod tests {
use super::{AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
fn records_model() -> Model {
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("Measure").unwrap().add_item("Revenue");
m.category_mut("Measure").unwrap().add_item("Cost");
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("Measure".into(), "Revenue".into()),
]),
CellValue::Number(100.0),
);
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("Measure".into(), "Cost".into()),
]),
CellValue::Number(50.0),
);
m
}
#[test]
fn records_mode_activated_when_index_and_dim_on_axes() {
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());
assert_eq!(layout.row_count(), 2); // 2 cells
}
#[test]
fn records_mode_cell_key_editable_for_value_column() {
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 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 region_col = cols.iter().position(|c| c == "Region").unwrap();
assert!(
layout.cell_key(0, region_col).is_none(),
"Region column should not be editable"
);
}
#[test]
fn records_mode_cell_key_maps_to_real_cell() {
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 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");
}
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
@ -483,4 +720,91 @@ mod tests {
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
assert_eq!(layout.data_row_to_visual(2), None);
}
#[test]
fn data_col_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
assert_eq!(layout.data_col_to_visual(1), Some(3));
assert_eq!(layout.data_col_to_visual(2), None);
}
#[test]
fn row_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.row_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.row_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn row_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
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");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.row_group_for(0), None);
}
#[test]
fn col_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.col_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.col_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn col_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
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");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.col_group_for(0), None);
}
}

View File

@ -1,7 +1,7 @@
pub mod axis;
pub mod layout;
pub mod view;
pub mod types;
pub use axis::Axis;
pub use layout::{AxisEntry, GridLayout};
pub use view::View;
pub use types::View;

View File

@ -41,15 +41,20 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Auto-assign: first → Row, second → Column, rest → Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
let axis = if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
// Virtual categories (names starting with `_`) default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
let axis = if cat_name.starts_with('_') {
Axis::None
} else {
Axis::Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
if rows == 0 {
Axis::Row
} else if cols == 0 {
Axis::Column
} else {
Axis::Page
}
};
self.category_axes.insert(cat_name.to_string(), axis);
}
@ -148,12 +153,13 @@ impl View {
self.col_offset = 0;
}
/// Cycle axis for a category: Row → Column → Page → Row
/// Cycle axis for a category: Row → Column → Page → None → Row
pub fn cycle_axis(&mut self, cat_name: &str) {
let next = match self.axis_of(cat_name) {
Axis::Row => Axis::Column,
Axis::Column => Axis::Page,
Axis::Page => Axis::Row,
Axis::Page => Axis::None,
Axis::None => Axis::Row,
};
self.set_axis(cat_name, next);
self.selected = (0, 0);
@ -302,9 +308,17 @@ mod tests {
}
#[test]
fn cycle_axis_page_to_row() {
fn cycle_axis_page_to_none() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::None);
}
#[test]
fn cycle_axis_none_to_row() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.set_axis("Time", Axis::None);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::Row);
}
@ -351,7 +365,7 @@ mod prop_tests {
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
let all_axes = [Axis::Row, Axis::Column, Axis::Page];
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
for c in &cats {
let count = all_axes.iter()
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
@ -377,7 +391,7 @@ mod prop_tests {
fn set_axis_updates_axis_of(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
@ -392,7 +406,7 @@ mod prop_tests {
fn set_axis_exclusive(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }