Compare commits
273 Commits
d915908354
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 21fc03cf18 | |||
| 47313c4e80 | |||
| 6362078032 | |||
| 8242ef3dbe | |||
| c158a8b99e | |||
| d99cb5ac8c | |||
| 4e37e12f9a | |||
| a900f147b5 | |||
| 489e2805e8 | |||
| f272a9d459 | |||
| ff74d619a3 | |||
| 5807464fc7 | |||
| bd17aed169 | |||
| 79dc54de21 | |||
| 9efbed403a | |||
| 03c7c00b25 | |||
| 08f190a036 | |||
| d20eb75a0b | |||
| 30383f203e | |||
| cece34a1d4 | |||
| 79f78c496e | |||
| fc9d9cb52a | |||
| 0d75c7bd0b | |||
| a22478eb87 | |||
| 242ddebb49 | |||
| 030865a0ff | |||
| ded35f705c | |||
| 7c00695398 | |||
| 23c7c530e3 | |||
| 1c16dc17e8 | |||
| f1229a60e4 | |||
| 3fbf56ec8b | |||
| 6d4b19a940 | |||
| 709f2df11f | |||
| 5fbc73269f | |||
| dba8a5269e | |||
| 3f69f88709 | |||
| 38f83b2417 | |||
| ee5fc89e43 | |||
| efab0cc32e | |||
| d8375ceaa7 | |||
| 6f291ccd04 | |||
| 9710fb534e | |||
| f02d905aac | |||
| c79498b04b | |||
| 60a8559a7f | |||
| 1cea06e14b | |||
| 8b7b45587b | |||
| d551d53eb4 | |||
| f3996da2ec | |||
| 648e50860e | |||
| bdbfe08476 | |||
| 1ae6187285 | |||
| 5a7ba5fb30 | |||
| 35e2626a7d | |||
| 8baa4c4865 | |||
| f019577810 | |||
| a94abd6e6c | |||
| cb35e38df9 | |||
| 6370f8b19f | |||
| af74dc3d3f | |||
| d4e948827b | |||
| ed1ee7e23a | |||
| c48a5cd575 | |||
| 53b13d4942 | |||
| 04058a48f9 | |||
| 37411bf747 | |||
| 5e90fbe0f7 | |||
| 1350e875c5 | |||
| d4f6efd1c0 | |||
| ee6739158e | |||
| 537819577a | |||
| b2d30d5a20 | |||
| a8dbb86d4f | |||
| 4583b59520 | |||
| 21a5ea9d55 | |||
| 1817494db2 | |||
| c701534053 | |||
| 7fea5f67ed | |||
| c3fb8669c2 | |||
| c8b9d29690 | |||
| 1690fc317b | |||
| 07c8f4a40a | |||
| 70e7cfbef7 | |||
| d34e8eb313 | |||
| 4d7d91257d | |||
| 001744f5cf | |||
| 326e245c48 | |||
| afbcf7b3ff | |||
| e6c93a24d9 | |||
| 23a876d556 | |||
| fd69126cdc | |||
| 767d524d4b | |||
| 24c59fbf40 | |||
| 4686f47026 | |||
| 737d14a5c0 | |||
| 32d215f3d6 | |||
| a61d7aa229 | |||
| e00f2e032e | |||
| 280339ea10 | |||
| 6239ac83ad | |||
| 08df85664e | |||
| d3d1df0be2 | |||
| 4751b800fd | |||
| ecd001e589 | |||
| 3338601a6c | |||
| 861b142dec | |||
| 769a5eb443 | |||
| b610f7ad66 | |||
| ad9ea30fc2 | |||
| 11bcc5d04d | |||
| fb8b6ca053 | |||
| ab7e00a217 | |||
| 35ed6a13bf | |||
| 637178f3f6 | |||
| 09df7bf181 | |||
| 96d783a1c5 | |||
| e67f4d5a92 | |||
| 4c8ba6400b | |||
| db170a10b8 | |||
| d14ec443c2 | |||
| a83a4f604f | |||
| c8607b78ba | |||
| 687cf80698 | |||
| 96a4cda368 | |||
| 5f71321980 | |||
| a3d8adfb45 | |||
| da076eadc8 | |||
| 433a20928a | |||
| 7dd9d906c1 | |||
| 2b1f42d8bf | |||
| d49bcf0060 | |||
| bbc009b088 | |||
| 33676b8abd | |||
| 1813d2a662 | |||
| fb85e98abe | |||
| aabf8c1ed7 | |||
| 2dbedd36ce | |||
| f4e4b75b20 | |||
| 956cb5692c | |||
| cb24cce1f0 | |||
| a3a74d2787 | |||
| 3885fc19c8 | |||
| 5566d7349b | |||
| 4b11b6e321 | |||
| de047ddf1a | |||
| 8f3a54bb38 | |||
| 85ab5a3a54 | |||
| 406debbc7c | |||
| 6f3af34056 | |||
| 386b9f6b27 | |||
| 451f361626 | |||
| 5e157d8c84 | |||
| b3d80e2986 | |||
| 617175d191 | |||
| 4d537dec06 | |||
| 9b8f939245 | |||
| 90c971539c | |||
| d76efa9e0e | |||
| 64e9d327db | |||
| 4f6c4aecd9 | |||
| 56838c0a61 | |||
| def4f0b7df | |||
| e0171c758f | |||
| 812760233b | |||
| a17802e07d | |||
| e8af67e2b0 | |||
| 58372a8d8a | |||
| b450d1add6 | |||
| 2767d83665 | |||
| b7e5115a8e | |||
| 870952a2e3 | |||
| 395410b357 | |||
| b5418f2eea | |||
| ebf4a5ea18 | |||
| fbd672d5ed | |||
| 9e02939b66 | |||
| 121b7d2dd7 | |||
| ad95bc34a9 | |||
| 7bd2296a22 | |||
| 21f3e2c58e | |||
| df98f6d524 | |||
| de973ef641 | |||
| eb83df9984 | |||
| 55cad99ae1 | |||
| 5fe553b57a | |||
| 0c04d63542 | |||
| a8cc8e4de9 | |||
| 3bb0977e18 | |||
| d91c1e24c6 | |||
| 32fe988d64 | |||
| 95b88a538d | |||
| cfb389ea09 | |||
| 496a385c15 | |||
| cc6504e9b6 | |||
| 34df174b9b | |||
| 640fb353a1 | |||
| ab92775357 | |||
| 94bc3ca282 | |||
| f56ca2c66a | |||
| 78df3a4949 | |||
| 19645a34cf | |||
| 67041dd4a5 | |||
| b2d633eb7d | |||
| 401a63f544 | |||
| 3d11daca18 | |||
| ab5f3a5a86 | |||
| 377d417e5a | |||
| 872c4c6c5d | |||
| d3a1a57c78 | |||
| 82ad459c4e | |||
| 6c211e5421 | |||
| 0f1de6ba58 | |||
| 89fdb27d6c | |||
| e2ff9cf98e | |||
| c6c8ac2c69 | |||
| 35946afc91 | |||
| 649d80cb35 | |||
| a45390b7a9 | |||
| 830869d91c | |||
| 32716ebc16 | |||
| 00c62d85b7 | |||
| 64ab352490 | |||
| 909c20bcbd | |||
| 1e8bc7a135 | |||
| 2be1eeae5d | |||
| b8cff2488c | |||
| 4941b6f44c | |||
| 630367a9b0 | |||
| 3c561adf05 | |||
| 0db89b1e3a | |||
| d8f7d9a501 | |||
| ebe8df89ee | |||
| 5cd3cf3c18 | |||
| e976b3c49a | |||
| b7e4316cef | |||
| 56839b81d2 | |||
| 67fca18200 | |||
| 387190c9f7 | |||
| dfae4a882d | |||
| 9afa13f78a | |||
| bfc30cb7b2 | |||
| c188ce3f9d | |||
| f2bb8ec2a7 | |||
| 038c99c473 | |||
| f7436e73ba | |||
| 0c751b7b8b | |||
| 9421d01da5 | |||
| 567ca341f7 | |||
| 6647be30fa | |||
| ac0c538c98 | |||
| 9f5b7f602a | |||
| 4525753109 | |||
| 4233d3fbf4 | |||
| a73fe160c7 | |||
| 5a251a1cbe | |||
| dd728ccac8 | |||
| 77b33b7a85 | |||
| e831648b18 | |||
| be277f43c2 | |||
| edd6431444 | |||
| 5136aadd86 | |||
| b9da06c55f | |||
| edd33d6dee | |||
| 2c9d9c7de7 | |||
| 368b303eac | |||
| fe74cc5fcb | |||
| b9818204a4 | |||
| 1d5edd2c09 | |||
| da93145de5 | |||
| fcfdc09732 | |||
| 23e26f0e06 | |||
| 2cf1123bcb |
73
.beads/.gitignore
vendored
Normal file
73
.beads/.gitignore
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Dolt database (managed by Dolt, not git)
|
||||||
|
dolt/
|
||||||
|
embeddeddolt/
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
bd.sock
|
||||||
|
bd.sock.startlock
|
||||||
|
sync-state.json
|
||||||
|
last-touched
|
||||||
|
.exclusive-lock
|
||||||
|
|
||||||
|
# Daemon runtime (lock, log, pid)
|
||||||
|
daemon.*
|
||||||
|
|
||||||
|
# Interactions log (runtime, not versioned)
|
||||||
|
interactions.jsonl
|
||||||
|
|
||||||
|
# Push state (runtime, per-machine)
|
||||||
|
push-state.json
|
||||||
|
|
||||||
|
# Lock files (various runtime locks)
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Credential key (encryption key for federation peer auth — never commit)
|
||||||
|
.beads-credential-key
|
||||||
|
|
||||||
|
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||||
|
.local_version
|
||||||
|
|
||||||
|
# Worktree redirect file (contains relative path to main repo's .beads/)
|
||||||
|
# Must not be committed as paths would be wrong in other clones
|
||||||
|
redirect
|
||||||
|
|
||||||
|
# Sync state (local-only, per-machine)
|
||||||
|
# These files are machine-specific and should not be shared across clones
|
||||||
|
.sync.lock
|
||||||
|
export-state/
|
||||||
|
export-state.json
|
||||||
|
|
||||||
|
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
|
||||||
|
ephemeral.sqlite3
|
||||||
|
ephemeral.sqlite3-journal
|
||||||
|
ephemeral.sqlite3-wal
|
||||||
|
ephemeral.sqlite3-shm
|
||||||
|
|
||||||
|
# Dolt server management (auto-started by bd)
|
||||||
|
dolt-server.pid
|
||||||
|
dolt-server.log
|
||||||
|
dolt-server.lock
|
||||||
|
dolt-server.port
|
||||||
|
dolt-server.activity
|
||||||
|
|
||||||
|
# Corrupt backup directories (created by bd doctor --fix recovery)
|
||||||
|
*.corrupt.backup/
|
||||||
|
|
||||||
|
# Backup data (auto-exported JSONL, local-only)
|
||||||
|
backup/
|
||||||
|
|
||||||
|
# Per-project environment file (Dolt connection config, GH#2520)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Legacy files (from pre-Dolt versions)
|
||||||
|
*.db
|
||||||
|
*.db?*
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
db.sqlite
|
||||||
|
bd.db
|
||||||
|
# NOTE: Do NOT add negation patterns here.
|
||||||
|
# They would override fork protection in .git/info/exclude.
|
||||||
|
# Config files (metadata.json, config.yaml) are tracked by git by default
|
||||||
|
# since no pattern above ignores them.
|
||||||
81
.beads/README.md
Normal file
81
.beads/README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Beads - AI-Native Issue Tracking
|
||||||
|
|
||||||
|
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
|
||||||
|
|
||||||
|
## What is Beads?
|
||||||
|
|
||||||
|
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
|
||||||
|
|
||||||
|
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create new issues
|
||||||
|
bd create "Add user authentication"
|
||||||
|
|
||||||
|
# View all issues
|
||||||
|
bd list
|
||||||
|
|
||||||
|
# View issue details
|
||||||
|
bd show <issue-id>
|
||||||
|
|
||||||
|
# Update issue status
|
||||||
|
bd update <issue-id> --claim
|
||||||
|
bd update <issue-id> --status done
|
||||||
|
|
||||||
|
# Sync with Dolt remote
|
||||||
|
bd dolt push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Issues
|
||||||
|
|
||||||
|
Issues in Beads are:
|
||||||
|
- **Git-native**: Stored in Dolt database with version control and branching
|
||||||
|
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
|
||||||
|
- **Branch-aware**: Issues can follow your branch workflow
|
||||||
|
- **Always in sync**: Auto-syncs with your commits
|
||||||
|
|
||||||
|
## Why Beads?
|
||||||
|
|
||||||
|
✨ **AI-Native Design**
|
||||||
|
- Built specifically for AI-assisted development workflows
|
||||||
|
- CLI-first interface works seamlessly with AI coding agents
|
||||||
|
- No context switching to web UIs
|
||||||
|
|
||||||
|
🚀 **Developer Focused**
|
||||||
|
- Issues live in your repo, right next to your code
|
||||||
|
- Works offline, syncs when you push
|
||||||
|
- Fast, lightweight, and stays out of your way
|
||||||
|
|
||||||
|
🔧 **Git Integration**
|
||||||
|
- Automatic sync with git commits
|
||||||
|
- Branch-aware issue tracking
|
||||||
|
- Dolt-native three-way merge resolution
|
||||||
|
|
||||||
|
## Get Started with Beads
|
||||||
|
|
||||||
|
Try Beads in your own projects:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Beads
|
||||||
|
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
|
||||||
|
|
||||||
|
# Initialize in your repo
|
||||||
|
bd init
|
||||||
|
|
||||||
|
# Create your first issue
|
||||||
|
bd create "Try out Beads"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
|
||||||
|
- **Quick Start Guide**: Run `bd quickstart`
|
||||||
|
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Beads: Issue tracking that moves at the speed of thought* ⚡
|
||||||
56
.beads/config.yaml
Normal file
56
.beads/config.yaml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# Beads Configuration File
|
||||||
|
# This file configures default behavior for all bd commands in this repository
|
||||||
|
# All settings can also be set via environment variables (BD_* prefix)
|
||||||
|
# or overridden with command-line flags
|
||||||
|
|
||||||
|
# Issue prefix for this repository (used by bd init)
|
||||||
|
# If not set, bd init will auto-detect from directory name
|
||||||
|
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
|
||||||
|
# issue-prefix: ""
|
||||||
|
|
||||||
|
# Use no-db mode: JSONL-only, no Dolt database
|
||||||
|
# When true, bd will use .beads/issues.jsonl as the source of truth
|
||||||
|
# no-db: false
|
||||||
|
|
||||||
|
# Enable JSON output by default
|
||||||
|
# json: false
|
||||||
|
|
||||||
|
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
|
||||||
|
# 0 = hide titles, N > 0 = truncate to N characters
|
||||||
|
# output:
|
||||||
|
# title-length: 255
|
||||||
|
|
||||||
|
# Default actor for audit trails (overridden by BEADS_ACTOR or --actor)
|
||||||
|
# actor: ""
|
||||||
|
|
||||||
|
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
|
||||||
|
# When enabled, new events are appended incrementally using a high-water mark.
|
||||||
|
# Use 'bd export --events' to trigger manually regardless of this setting.
|
||||||
|
# events-export: false
|
||||||
|
|
||||||
|
# Multi-repo configuration (experimental - bd-307)
|
||||||
|
# Allows hydrating from multiple repositories and routing writes to the correct database
|
||||||
|
# repos:
|
||||||
|
# primary: "." # Primary repo (where this database lives)
|
||||||
|
# additional: # Additional repos to hydrate from (read-only)
|
||||||
|
# - ~/beads-planning # Personal planning repo
|
||||||
|
# - ~/work-planning # Work planning repo
|
||||||
|
|
||||||
|
# JSONL backup (periodic export for off-machine recovery)
|
||||||
|
# Auto-enabled when a git remote exists. Override explicitly:
|
||||||
|
# backup:
|
||||||
|
# enabled: false # Disable auto-backup entirely
|
||||||
|
# interval: 15m # Minimum time between auto-exports
|
||||||
|
# git-push: false # Disable git push (export locally only)
|
||||||
|
# git-repo: "" # Separate git repo for backups (default: project repo)
|
||||||
|
|
||||||
|
# Integration settings (access with 'bd config get/set')
|
||||||
|
# These are stored in the database, not in this file:
|
||||||
|
# - jira.url
|
||||||
|
# - jira.project
|
||||||
|
# - linear.url
|
||||||
|
# - linear.api-key
|
||||||
|
# - github.org
|
||||||
|
# - github.repo
|
||||||
|
|
||||||
|
sync.remote: "git+ssh://git@git.fiddlerwoaroof.com/u/edwlan/improvise.git"
|
||||||
24
.beads/hooks/post-checkout
Executable file
24
.beads/hooks/post-checkout
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
if [ $_bd_exit -eq 124 ]; then
|
||||||
|
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bd hooks run post-checkout "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||||
1
.beads/hooks/post-commit
Executable file
1
.beads/hooks/post-commit
Executable file
@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
25
.beads/hooks/post-merge
Executable file
25
.beads/hooks/post-merge
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
if [ $_bd_exit -eq 124 ]; then
|
||||||
|
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bd hooks run post-merge "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||||
|
|
||||||
24
.beads/hooks/pre-commit
Executable file
24
.beads/hooks/pre-commit
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
if [ $_bd_exit -eq 124 ]; then
|
||||||
|
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bd hooks run pre-commit "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||||
25
.beads/hooks/pre-push
Executable file
25
.beads/hooks/pre-push
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
if [ $_bd_exit -eq 124 ]; then
|
||||||
|
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bd hooks run pre-push "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||||
|
|
||||||
24
.beads/hooks/prepare-commit-msg
Executable file
24
.beads/hooks/prepare-commit-msg
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# --- BEGIN BEADS INTEGRATION v1.0.2 ---
|
||||||
|
# This section is managed by beads. Do not remove these markers.
|
||||||
|
if command -v bd >/dev/null 2>&1; then
|
||||||
|
export BD_GIT_HOOK=1
|
||||||
|
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
|
||||||
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
|
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
if [ $_bd_exit -eq 124 ]; then
|
||||||
|
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
bd hooks run prepare-commit-msg "$@"
|
||||||
|
_bd_exit=$?
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -eq 3 ]; then
|
||||||
|
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
|
||||||
|
_bd_exit=0
|
||||||
|
fi
|
||||||
|
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
|
||||||
|
fi
|
||||||
|
# --- END BEADS INTEGRATION v1.0.2 ---
|
||||||
125
.beads/issues.jsonl
Normal file
125
.beads/issues.jsonl
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
{"id":"improvise-4yc","title":"Malformed numbers in .improv file silently become 0.0","description":"crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:17Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-dqn","title":"CyclePanelFocus does not actually cycle between panels","description":"The CyclePanelFocus command at src/command/cmd/panel.rs:299-313 is documented as 'Tab through open panels' but the if/else if chain always resolves to the first open panel (formula \u003e category \u003e view). Pressing the bound key repeatedly never rotates. Test at panel.rs:84 only asserts a panel mode is entered, not rotation. Fix: either implement real rotation using ctx.mode to determine 'next' open panel, or rename to FocusFirstOpenPanel. Also replace the three bool fields with an enum of open panel states.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:07Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-fpo","title":"Dynamic _Measure: formula targets auto-included","description":"_Measure should dynamically include all formula targets without add_formula manually adding items. Formula refs should resolve against Model::formulas as fallback. CommitFormula should not need to pick a target category for _Measure.","status":"closed","priority":1,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T10:14:14Z","created_by":"spot","updated_at":"2026-04-09T20:52:01Z","closed_at":"2026-04-09T20:52:01Z","close_reason":"Dynamic _Measure, optional target_category, atomic formula eval on load, skip virtual category persistence","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-h4r","title":"Replace ad-hoc parser with pest grammar","description":"The pest grammar at src/persistence/improv.pest defines the .improv format spec. Replace the hand-written line-scanner in parse_md with a pest-derived parser that walks the grammar's parse tree. This eliminates drift between spec and implementation.","status":"closed","priority":1,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T09:03:53Z","created_by":"spot","updated_at":"2026-04-09T09:32:38Z","closed_at":"2026-04-09T09:32:38Z","close_reason":"Replaced ad-hoc line scanner with pest-derived parser. Grammar at improv.pest is now the single source of truth for both parsing and test generation. Grammar-walking generator uses pest_meta to read the grammar AST at test time and produce random valid files as proptest entropy.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-5xd","title":"Rename Measure to _Measure (virtual category)","description":"Measure should be a virtual category (_Measure) like _Index and _Dim. This exempts it from the 12-category limit and follows naming conventions. Requires updating: import wizard, evaluate_aggregated hardcoded reference, persistence parser, demo.improv, and CategoryKind.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T08:52:11Z","created_by":"Edward Langley","updated_at":"2026-04-09T09:40:23Z","closed_at":"2026-04-09T09:40:23Z","close_reason":"Measure renamed to _Measure (VirtualMeasure kind). Fixed-point formula evaluation implemented via recompute_formulas cache. Formulas now resolve correctly with hidden dimensions.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-ubu","title":"Circular formula references produce ERR instead of stack overflow","description":"Added depth limit (16) to formula evaluation. Circular/self-referencing formulas return CellValue::Error(\"circular\") instead of infinite recursion. Errors propagate through expression tree via Result. Already fixed.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:35:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:36:08Z","closed_at":"2026-04-09T08:36:08Z","close_reason":"Fixed in commit 56bf736","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-di3","title":"Bug: AddFormula doesn't add target item to category","description":"When adding a formula interactively (e.g. Margin = Profit / Revenue), the formula is registered but the target item (Margin) is never added to the target category (Measure). The grid layout never creates cells for it, so the formula is invisible. Found during demo recording.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-09T08:24:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:25:15Z","closed_at":"2026-04-09T08:25:15Z","close_reason":"Fixed: AddFormula::apply now calls category_mut().add_item() before registering the formula. Regression test added.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-n1h","title":"1.5 Create examples/demo.improv and examples/demo.csv","description":"Create two synthetic example files with obviously-fake data. demo.csv: ~30-50 rows with Date, Region, Product, Customer, Revenue, Cost columns. demo.improv: result of importing demo.csv, optionally with interesting default view and sample Profit formula. Data must be obviously synthetic.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:53Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:16:33Z","closed_at":"2026-04-09T07:16:33Z","close_reason":"Created examples/demo.csv (40 rows, obviously-fake data with fictional companies) and examples/demo.improv (generated via headless import with Profit formula and a useful default view). Added import command to README.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-bv1","title":"1.4 Audit CSV quote handling","description":"Verify csv_parser correctly handles RFC 4180 quoted fields: embedded commas, escaped quotes. Ensure it uses the csv crate, not manual split. Add unit test round-tripping a row with embedded comma and escaped quotes. If fundamentally broken, add Known Limitations note to README instead.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:51Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:58:41Z","closed_at":"2026-04-09T06:58:41Z","close_reason":"Audit complete. The csv crate handles all RFC 4180 cases correctly: embedded commas, escaped quotes, embedded newlines. Added 4 regression tests. No bugs found.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-2fr","title":"1.3 Verify publish-readiness (cargo publish --dry-run)","description":"Run cargo publish --dry-run from inside nix develop. Fix any errors or warnings (missing license file, files too large, dependency issues). Do not actually publish.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:49Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:21:22Z","closed_at":"2026-04-09T07:21:22Z","close_reason":"cargo publish --dry-run passes cleanly: 72 files, 952KB, no errors or warnings.","dependencies":[{"issue_id":"improvise-2fr","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:20:17Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:10:05Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-tvt","title":"Route mutations through Workbook facade instead of app.workbook.model chains","description":"40+ sites follow the pattern 'if let Some(cat) = app.workbook.model.category_mut(\u0026name) { cat.add_item(\u0026item); }' (src/ui/effect.rs:38, 54, 99 and many test setups in src/command/cmd/mod.rs, src/ui/grid.rs).\\n\\nSimilarly .views.get().unwrap().axis_of() appears in 11 sites (workbook.rs:167-177, model/types.rs:1868-1890, persistence:943-946,1165) even though Workbook::active_view() / active_view_mut() already exist and are used correctly in some places.\\n\\nFix: add facade methods on Workbook: add_item(cat, item), remove_item(cat, item), view_axis(view_name, cat), sort_cells(), cell_count(). Migrate the existing chains to use them.\\n\\nCorrectness win: (a) Workbook becomes a real facade with the option to enforce invariants (e.g., notify views when items change); (b) Tests and effects stop reaching through three layers; (c) future refactors to Model/DataStore internals don't cascade into 40+ call sites.\\n\\n~60 LOC in production + simplification of many tests.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:18Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:18Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-2hi","title":"Extract AppMode dispatch methods (mode_label, mode_style, mode_key)","description":"AppMode is matched in ~52 sites across 8 files to derive UI labels, styles, and mode keys. Each match arm is a small piece of per-variant logic that should live on the variant itself.\\n\\nHot spots:\\n- src/draw.rs:119-149 — mode_name() and mode_style() match blocks (16+ arms)\\n- src/command/keymap.rs:69-89 — ModeKey::from_app_mode match (16 arms)\\n- src/ui/{formula,category,view}_panel.rs — matches!(mode, AppMode::X) checks\\n- src/ui/tile_bar.rs, src/ui/effect.rs:898-900, src/command/cmd/registry.rs:376\\n- src/ui/app.rs: 13 scattered matches!() checks\\n\\nFix: add methods on AppMode — mode_label() -\u003e \u0026'static str, mode_style() -\u003e Style, mode_key() -\u003e Option\u003cModeKey\u003e, panel_mode() -\u003e Option\u003cPanel\u003e, is_text_entry_mode() -\u003e bool (partially present via minibuffer()). Also extend the existing minibuffer() with buffer_key() convenience.\\n\\nCorrectness win: adding a new AppMode variant requires updating the enum's methods (one file), not chasing 8 files with match blocks. Exhaustive match inside the method body still forces the compiler to flag every new variant.\\n\\n~80 LOC collapsed and 7 files depend on fewer concerns of AppMode.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:09Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:09Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-2lh","title":"Migrate name-string comparisons to CategoryKind enum checks","description":"8 sites compare category names as strings against virtual-category prefixes ('_Measure', '_Index', '_Dim') instead of checking the typed CategoryKind enum. Adding a new CategoryKind variant won't produce a compile error at any of these sites.\\n\\nSites:\\n- crates/improvise-core/src/model/types.rs:215, 226, 429, 636 (target_category == '_Measure', cat_name == '_Measure')\\n- crates/improvise-io/src/persistence/mod.rs:213, 226 (formula target comparison)\\n- crates/improvise-core/src/view/layout.rs:108 (c == '_Index' \u0026\u0026 c == '_Dim')\\n- src/command/cmd/grid.rs:306 (target_category == '_Measure')\\n\\nFix: add methods on CategoryKind: is_virtual(), is_measure(), is_persisted(), is_index_or_dim(). Look up the Category by name once, dispatch on its .kind.\\n\\nCorrectness win: the compiler flags every site when a new variant is added. Also removes the hardcoded-name dependency — if the underscore prefix convention ever changes, one place to edit.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:01:53Z","created_by":"Ed L","updated_at":"2026-04-16T19:01:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-6sq","title":"Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}","description":"command/cmd/mode.rs:254-307 has two commands (EditOrDrill, EnterEditAtCursorCmd) that each thread an edit_mode parameter. The drill-vs-edit decision is mode-independent; merging eliminates the fork.\\n\\nAbstraction: EnterEdit { mode: AppMode } — single command that checks aggregation state, pre-fills the buffer if editing, then enters the supplied mode.\\n\\nMatches the parameterization precedent from commit 30383f2 ('parameterize mode-related commands and effects'). ~50 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:39Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-6t3","title":"Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)","description":"command/cmd/commit.rs:349-439 has three commands with identical shape: read buffer named X, if empty abort, else emit AddY effect, mark dirty, optionally exit mode. Only the buffer name and the target effect type differ.\\n\\nAbstraction: CommitBuffer { kind: CommitKind } where CommitKind is Formula | Category | Item.\\n\\nCorrectness win: minibuffer commit semantics live in one place. New minibuffer modes (rename-view, rename-item) get correct semantics for free. Pairs with improvise-{text-entry keymap template} — once the commit is generic, the keymap template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly across all text-entry modes.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:33Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:33Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-k8h","title":"Consolidate minibuffer AppMode constructors","description":"ui/app.rs:54-185 has 7 AppMode minibuffer variants (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode) that each hand-build MinibufferConfig { buffer_key, prompt, color, .. }. The buffer_key string must match what AppendChar/PopChar/commit commands look up in ctx.buffers — nothing enforces this.\\n\\nAbstraction: impl AppMode { fn editing() -\u003e Self; fn command_mode() -\u003e Self; ... } with buffer_key drawn from a shared constant or enum.\\n\\nNote: full enum refactor to AppMode::Minibuffer { kind, config } is not worth the churn — most of the 35 match sites use { .. } and don't care about the payload; the constructor consolidation captures the correctness win (single buffer_key source) without the enum refactor.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:25Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:25Z","dependencies":[{"issue_id":"improvise-k8h","depends_on_id":"improvise-2hi","type":"blocks","created_at":"2026-04-16T12:02:40Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-4pg","title":"Extract panel mode keymap template","description":"command/keymap.rs:554-713 has three nearly-identical panel keymaps (FormulaPanel, CategoryPanel, ViewPanel) sharing Esc/Tab/j/k/F/C/V/: bindings. Only panel name varies.\\n\\nAbstraction: fn panel_keymap(panel: Panel) -\u003e Keymap\\n\\nCorrectness win: adding a 4th panel becomes a one-line call; prevents per-panel binding drift. Also helps keep CyclePanelFocus keybinding consistent once improvise-dqn is fixed.\\n\\n~80 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:24Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-9cn","title":"Introduce register_cmd! macro to prevent name drift","description":"src/command/cmd/registry.rs has 20+ register_pure(\u0026SomeCmd(vec![]), SomeCmd::parse) sites where the registered name string must match SomeCmd::name(). Nothing enforces this match — a typo produces a silent dispatch failure.\\n\\nAbstraction: register_cmd!(r, SomeCmd) macro that derives the name from the Cmd impl at compile time.\\n\\nCorrectness win: compile-time guarantee that the registered name matches Cmd::name(). Subsumes improvise-61f (the runtime invariant test); keeping 61f as a belt-and-braces check is fine but may be unnecessary once this lands.\\n\\n~40 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:22Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:22Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-w3q","title":"Extract text-entry keymap template for minibuffer modes","description":"command/keymap.rs:770-966 hand-binds Enter/Esc/Tab/Backspace/AnyChar for 8 modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, SearchMode). Only buffer name, commit command, and exit mode vary between them.\\n\\nAbstraction: fn text_entry_keymap(buffer: \u0026str, commit: \u0026str, exit: AppMode, parent: Option\u003cArc\u003cKeymap\u003e\u003e) -\u003e Keymap\\n\\nCorrectness win: impossible to omit clear-buffer in one mode's Esc sequence (the exact class of bug fixed in a recent refactor). New text modes inherit semantics for free. Pairs naturally with generic CommitBuffer(kind) — once the commit primitive is unified, the template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly.\\n\\n~150 LOC collapsed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:14Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:14Z","dependencies":[{"issue_id":"improvise-w3q","depends_on_id":"improvise-6t3","type":"blocks","created_at":"2026-04-16T11:54:32Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-0bf","title":"Inconsistent float-equality tolerance in formula eval","description":"crates/improvise-core/src/model/types.rs uses different float-equality strategies: division checks 'rv == 0.0' exactly (:674), while = and != comparisons use '1e-10' epsilon (:555, :749). A formula like IF(x = 0, ..., y/x) with x = 1e-11 treats x as zero in the condition but divides without error. Either unify on a single FLOAT_EQ_EPSILON constant or document the design choice explicitly in comments.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:35Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:35Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-kdp","title":"Duplicated aggregation dispatch block","description":"crates/improvise-core/src/model/types.rs:305-312 and :598-604 contain identical Sum/Avg/Min/Max/Count match blocks against AggFunc. If business logic changes (NaN handling, default agg), updates must be made in both sites. Fix: extract fn apply_agg(values: \u0026[f64], func: \u0026AggFunc) -\u003e f64 as a pure helper.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:29Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:29Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-nrs","title":"Duplicated find_item_category helper in eval paths","description":"crates/improvise-core/src/model/types.rs has identical nested find_item_category helpers at lines 420 and 627 (inside eval_formula_with_cache and eval_formula_depth). Both search all categories for an item, then fall back to formula targets. Fix: extract as Model::find_item_category(\u0026self, item: \u0026str) -\u003e Option\u003c\u0026str\u003e so bug fixes apply in one place.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:26Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:26Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-6os","title":"O(n^2) stem deduplication in recompute_formulas","description":"crates/improvise-core/src/model/types.rs:346-359 accumulates unique formula stems using 'if !stems.contains(\u0026stripped) { stems.push(stripped); }'. This is O(n^2) over every cell for every formula category. recompute_formulas runs at startup and after every data mutation. Large workbooks will show visible slowdown. Fix: collect into HashSet\u003cCellKey\u003e, convert to Vec once for iteration order.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:24Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:24Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-dwe","title":"Split App into AppState + App wrapper (in-place, no crate change)","description":"Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from \u0026mut App to \u0026mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes.","design":"AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(\u0026mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert(\"edit\", self.initial_value.clone()); state.mode = self.target_mode.clone();","acceptance_criteria":"(1) AppState struct exists; App wraps it. (2) Effect::apply takes \u0026mut AppState, not \u0026mut App. (3) CmdContext.workbook still resolves to \u0026AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change.","notes":"Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T06:28:47Z","created_by":"spot","updated_at":"2026-04-16T23:02:32Z","closed_at":"2026-04-16T23:02:32Z","dependencies":[{"issue_id":"improvise-dwe","depends_on_id":"improvise-vb4","type":"supersedes","created_at":"2026-04-16T16:02:31Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-3zq","title":"Records-mode Enter/Tab at bottom-right: persist empty record, re-enter editing","description":"Two related bugs at the bottom-right cell of records view:\n\n1. Enter on bottom-right cell re-enters editing at the same cell (because EnterAdvance stays put at bottom-right and EnterEditAtCursor then re-edits). Expected: exit edit mode.\n\n2. Tab on bottom-right cell in records mode calls AddRecordRow, which SetCells with an empty CellKey when no page filters are active (the default records view has _Measure/foo on None axis, not Page). This writes a nonsense ' = 0' cell to the model that gets persisted (see sample.improv line 26: ' = 0'). Re-toggling records mode (RR) shows the empty record at the top of the table.\n\nRoot causes:\n- CommitAndAdvance (Down) always appends EnterEditAtCursor, even when no forward motion is possible.\n- AddRecordRow::execute unconditionally produces SetCell(empty_key, 0) when coords vec is empty, rather than bailing out.\n\nTests should be added in crates/improvise-persistence (or wherever) that demonstrate ' = 0' is not persisted, plus command-level tests that:\n- CommitAndAdvance(Down) at bottom-right produces a non-editing mode change.\n- AddRecordRow with no page filters produces no SetCell effect.","acceptance_criteria":"1. AddRecordRow with empty coords emits only a status message, no SetCell.\n2. commit-cell-edit (Enter) at bottom-right exits editing (lands in Normal/RecordsNormal).\n3. Tab at bottom-right of records view does not create an empty cell in the model.\n4. All new tests fail on main before the fix; pass after.","notes":"Bug #1 (Enter at bottom-right re-enters editing) — fixed via effect-chain abort mechanism:\n- Added App::abort_effects flag; apply_effects short-circuits and resets per batch\n- New AbortChain effect sets the flag\n- EnterAdvance at bottom-right now emits only AbortChain\n- CommitAndAdvance pushes change_mode(exit_mode_for(edit_mode)) BEFORE advance; trailing EnterEditAtCursor lifts back to editing only if advance succeeded\n\nBug #2 (Tab persists empty record across RR) — fixed by cleanup-on-leave:\n- New CleanEmptyRecords effect removes cells with empty CellKey\n- ToggleRecordsMode on leave emits [CleanEmptyRecords, ViewBack, status] — inverse of the SortData on entry\n\n5 new tests (2 integration demonstrating the bugs, 3 unit for the new primitives). 366 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"bug","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T06:01:20Z","created_by":"fido","updated_at":"2026-04-16T06:26:44Z","closed_at":"2026-04-16T06:26:44Z","close_reason":"Both bugs fixed; see notes for implementation summary. All 5 new tests pass; 366 workspace tests green; clippy clean.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-4ju","title":"Make EditOrDrill mode-agnostic; delete EnterEditMode","description":"EnterEditMode::execute currently checks ctx.mode.is_records() to decide between editing() and records_editing(). Per the keymap design principle (commands/effects must be mode-agnostic; modes switch keymaps), this is wrong. EditOrDrill (the only caller) should take an edit_mode parameter, and the records-mode keymap should pass records-editing while the normal keymap passes editing. Delete EnterEditMode; replace its registration with a parameterized binding for edit-or-drill.","notes":"Refactored both:\n- EditOrDrill (i/a) — now takes edit_mode: AppMode; keymap supplies it\n- EnterEditAtCursor effect + Cmd — now take target_mode: AppMode\n- CommitAndAdvance (Enter/Tab in editing modes) — now takes edit_mode: AppMode\n- Records-normal keymap overrides i/a to pass records-editing\n- Records-editing keymap overrides Enter/Tab to pass records-editing\n- Records-normal o sequence now passes records-editing\n- Shared parse_mode_name helper in registry.rs\n\nNo is_records() checks remain in any of these commands/effects.\nTests added for each parameterization (5 new tests).\n551 tests pass; clippy clean.","status":"closed","priority":2,"issue_type":"task","assignee":"fido","owner":"el-github@elangley.org","created_at":"2026-04-16T05:04:39Z","created_by":"fido","updated_at":"2026-04-16T05:35:11Z","closed_at":"2026-04-16T05:35:11Z","close_reason":"EditOrDrill, EnterEditAtCursor (effect + cmd), and CommitAndAdvance all parameterized with mode args from keymap; no runtime is_records() checks remain in those paths; 5 new tests cover the contract.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-kos","title":"_Dim and _Index should work as pivot axes outside Records/Drill view; must default to None axis on non-records views including empty models","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:15:40Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:37:10Z","closed_at":"2026-04-15T11:37:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-04-15T11:13:29Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-04-15T10:34:46Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-04-14T07:53:04Z","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0}
|
||||||
|
{"id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-04-14T07:50:54Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-04-14T07:53:18Z","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-04-14T07:53:17Z","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-04-14T07:48:38Z","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-04-14T07:48:36Z","dependency_count":0,"dependent_count":5,"comment_count":0}
|
||||||
|
{"id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-04-14T07:48:34Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-04-14T07:39:29Z","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"Terminal node of standalone epic (improvise-tm6). Depends on improvise-djm (main-thread), the worker-server issue, improvise-d31 (static shell + deploy), and all upstream prerequisites transitively.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-04-14T07:39:37Z","dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-04-14T07:38:45Z","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-04-14T07:34:08Z","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-04-14T07:33:49Z","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-04-14T07:33:47Z","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0}
|
||||||
|
{"id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-04-14T07:33:45Z","dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-04-14T07:30:04Z","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at bank-info.improv or any existing test file.","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-04-14T07:24:44Z","dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-gsw","title":"improvise-wasm-client crate (keymap + reduce_view in wasm)","description":"Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream.","design":"crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -\u003e Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -\u003e Option\u003cString\u003e (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -\u003e JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5).","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket.","notes":"Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:37Z","created_by":"spot","updated_at":"2026-04-14T07:39:34Z","dependencies":[{"issue_id":"improvise-gsw","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-gsw","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-q08","title":"improvise-ws-server binary (tokio + tungstenite session wrapper)","description":"New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet).","design":"crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters.","acceptance_criteria":"(1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back.","notes":"Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:14Z","created_by":"spot","updated_at":"2026-04-14T07:23:14Z","dependencies":[{"issue_id":"improvise-q08","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-q08","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-cqq","title":"Server-side projection emission layer + per-client viewport tracking","description":"After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work.","design":"Server holds HashMap\u003cSessionId, ClientSession\u003e where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range.","acceptance_criteria":"(1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec\u003c(SessionId, Command)\u003e to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region.","notes":"Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:12Z","created_by":"spot","updated_at":"2026-04-14T07:23:12Z","dependencies":[{"issue_id":"improvise-cqq","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:45Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqq","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-04-14T07:23:10Z","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":5,"comment_count":0}
|
||||||
|
{"id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-04-14T07:22:02Z","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-04-16T23:02:18Z","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-04-14T07:21:21Z","dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-3mm","title":"Step 5: Extract improvise-command crate below improvise-tui","description":"Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.","design":"AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).","acceptance_criteria":"(1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.","notes":"IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(\u0026mut App, \u0026Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders \u0026App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option\u003cImportWizard\u003e. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:53:59Z","created_by":"spot","updated_at":"2026-04-14T07:54:16Z","dependencies":[{"issue_id":"improvise-3mm","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-45v","title":"Step 4: Convert Effect trait to an enum (in-place, no crate change)","description":"Refactor Effect from a trait (Box\u003cdyn Effect\u003e with apply(\u0026self, \u0026mut App)) into a data enum, with a single apply(app: \u0026mut App, effect: \u0026Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.","design":"Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: \u0026mut App, e: \u0026Effect) function that matches exhaustively. Commands still return Vec\u003cEffect\u003e (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.","acceptance_criteria":"(1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec\u003cBox\u003cdyn Effect\u003e\u003e is replaced with Vec\u003cEffect\u003e in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.","notes":"Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:36:11Z","created_by":"spot","updated_at":"2026-04-16T06:26:50Z","closed_at":"2026-04-16T06:26:50Z","close_reason":"Superseded. Effect stays as a trait — the original blocker (command transitively depending on App via Effect::apply(\u0026mut App)) is resolved by splitting App into AppState (semantic state, in improvise-command) + App (rendering state wrapper, in improvise-tui). Effect::apply takes \u0026mut AppState; the few effects that currently read App-level state (layout, display_value) pre-compute those values in their Cmd::execute and pass them via the effect struct's own fields (self). No enum conversion, no dual trait, no noise. See improvise-3mm for the revised design.","dependencies":[{"issue_id":"improvise-45v","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-8zh","title":"Step 3: Extract improvise-io crate (persistence + import)","description":"Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.","acceptance_criteria":"(1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.","notes":"Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:23Z","created_by":"spot","updated_at":"2026-04-16T06:09:19Z","closed_at":"2026-04-16T06:09:19Z","close_reason":"Done on worktree-improvise-ewi-formula-crate. Created crates/improvise-io/ containing persistence/ + import/; depends only on improvise-core and improvise-formula (plus external: anyhow, chrono, csv, flate2, indexmap, pest, serde, serde_json). No crate::ui::* or crate::command::* imports in moved code. All 616 tests pass (219 main + 190 core + 65 formula + 142 io); clippy clean; 'cargo build -p improvise-io' succeeds standalone. Kept as one crate per acceptance criterion #5.","dependencies":[{"issue_id":"improvise-8zh","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:54:39Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-36h","title":"Step 2: Break Model↔View cycle and extract improvise-core crate","description":"Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap\u003cString,View\u003e, and view/layout.rs reads \u0026Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.","design":"Introduce pub struct Workbook { pub model: Model, pub views: IndexMap\u003cString, View\u003e, pub active_view: String, pub measure_agg: HashMap\u003cString, AggFunc\u003e }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a \u0026View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.","acceptance_criteria":"(1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).","notes":"This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:05Z","created_by":"spot","updated_at":"2026-04-16T05:50:39Z","closed_at":"2026-04-16T05:50:39Z","close_reason":"Phase A + Phase B complete on worktree-improvise-ewi-formula-crate. Model↔View cycle broken via Workbook wrapper (pure-data Model with view state lifted to Workbook); improvise-core sub-crate extracted containing model/, view/, workbook.rs, format.rs; depends only on improvise-formula; builds standalone via 'cargo build -p improvise-core'. All 616 tests pass, clippy clean, persistence round-trips intact.","dependencies":[{"issue_id":"improvise-36h","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:54:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-ewi","title":"Step 1: Set up Cargo workspace and extract improvise-formula crate","description":"Convert the repo to a Cargo workspace and pull formula/ (parser, AST, Expr/BinOp/AggFunc/Formula) out into a standalone improvise-formula crate. This is the warm-up step — formula/ has zero local deps so extraction is mechanical. Proves the workspace plumbing before tackling the structural work.","acceptance_criteria":"(1) Root Cargo.toml becomes a workspace. (2) crates/improvise-formula/ contains everything from src/formula/ and compiles standalone. (3) improvise (the main crate) depends on improvise-formula and re-exports or uses it at crate::formula paths. (4) All existing tests pass. (5) cargo build and cargo test work from the workspace root.","notes":"Touchpoints: src/formula/{ast,parser,mod}.rs move; src/model/types.rs (uses AggFunc, Formula) and src/persistence/mod.rs and src/import/wizard.rs update imports. No logic changes.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:34:50Z","created_by":"spot","updated_at":"2026-04-15T10:09:14Z","closed_at":"2026-04-15T10:09:14Z","close_reason":"Workspace set up, improvise-formula extracted as standalone sub-crate under crates/. Root crate re-exports as crate::formula via 'pub use improvise_formula as formula;' so existing paths unchanged. 35 formula tests run standalone in the sub-crate, 537 tests run in the root crate (572 total, matching pre-refactor). cargo build, cargo test --workspace, and cargo clippy --workspace all clean.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-xgl","title":"Epic: Split improvise into enforced-boundary workspace crates","description":"Convert the single improvise crate into a Cargo workspace of 5 crates so that module boundaries become compile-enforced rather than convention. Today nothing stops model/types.rs from reaching into ui::app; the goal is to make that a compile error. Target shape: improvise-formula (leaf) → improvise-core (model+view+format) → improvise-io (persistence+import) → improvise-command → improvise-tui (bin). See child issues for sequenced steps. The expensive work is breaking the Model↔View cycle and decoupling Effect from \u0026mut App — those are features, not costs.","design":"Target crate graph: improvise-formula (no deps) ← improvise-core (model+view+format) ← improvise-io (persistence+import) ← improvise-command ← improvise-tui. Two structural obstacles: (1) Model owns views: IndexMap\u003cString,View\u003e, creating a model↔view cycle — fix by moving views out into a Workbook wrapper. (2) Effect::apply takes \u0026mut App, so command transitively depends on App — fix by converting Effect to an enum with apply(app, effect) in the tui crate. Prefer the enum over a trait-based EffectTarget: effects become loggable/replayable (already gestured at in design-principles §1) and it matches the existing enum-heavy style (Binding, Axis, CategoryKind, BinOp).","notes":"Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:33:09Z","created_by":"spot","updated_at":"2026-04-14T06:33:09Z","dependencies":[{"issue_id":"improvise-xgl","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-13T23:55:06Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-45v","type":"blocks","created_at":"2026-04-13T23:55:05Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:55:04Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-xgl","depends_on_id":"improvise-ewi","type":"blocks","created_at":"2026-04-13T23:55:03Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-s0h","title":"'o' (add-record-row) broken in fresh data models","description":"Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"cursor-f4c497bb","owner":"el-github@elangley.org","created_at":"2026-04-09T22:18:58Z","created_by":"spot","updated_at":"2026-04-14T08:07:41Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-32r","title":"Drill into formula cell shows empty view","description":"Drilling into a cell whose value comes from a formula (e.g. Profit = Revenue - Cost) enters _Drill view with 0 rows. Formula cells have no raw data backing them, so the drill finds nothing. Should either show the constituent data rows that feed the formula, or display a meaningful message like 'Formula cell — no raw data'.","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T22:03:59Z","created_by":"spot","updated_at":"2026-04-14T06:35:38Z","closed_at":"2026-04-14T06:35:38Z","close_reason":"Strip formula target from drill key so matching_cells finds raw data. Test: drill_into_formula_cell_returns_data_records","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-gmx","title":"Bug: tile bar doesn't scroll when cursor moves offscreen","description":"When there are more category tiles than fit in the tile bar width, moving the cursor past the visible area doesn't scroll. The selected tile is simply not rendered. Found during demo recording with 8 categories.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T08:37:04Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:38:35Z","closed_at":"2026-04-09T08:38:35Z","close_reason":"Tile bar now auto-scrolls to keep selected tile visible, with ◀▶ overflow indicators.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-4ig","title":"Stress-test .improv parser for double quotes and commas","description":"Write targeted tests exposing bugs in the .improv parser around: embedded quotes in text values, commas in category/item names, commas in text values, escaped quotes, and round-trip fidelity for edge-case data.","notes":"Found 4 bugs:\n1. Newlines in text values break line-based parser (format_md writes newlines literally)\n2. Category names containing ', ' break coordinate split(\", \") parsing \n3. Item names containing '[...]' are misinterpreted as group syntax by parse_bracketed\n4. Extreme floats (subnormal numbers) don't round-trip through 4-decimal display format\n\nEmbedded double quotes actually work by coincidence (strip_prefix/strip_suffix only remove one char).\n\nTests added to src/persistence/mod.rs: 15 targeted unit tests + 5 proptest property tests (500 cases each).","status":"closed","priority":2,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T07:29:50Z","created_by":"spot","updated_at":"2026-04-09T09:01:59Z","closed_at":"2026-04-09T09:01:59Z","close_reason":"Fixed 6 parser bugs (newlines, commas in names, brackets in names, float precision, view name ambiguity, group brackets). Rewrote format: v2025-04-09 version line, Initial View, pipe quoting, Views→Formulas→Categories→Data order, comma-separated items. Added pest grammar, grammar-driven generator, 83 persistence tests including property tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-79u","title":"Bug: CommitFormula targets virtual category on empty model","description":"CommitFormula uses category_names().first() to pick the formula target category. category_names() includes virtual categories (_Index, _Dim). On a model with no regular categories, formulas would be assigned to _Index — which is meaningless since virtual categories exist only for drill-down rendering. Should filter to regular categories only, or show an error like 'Add at least one category first.' Found during test audit of command/cmd.rs.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:26:09Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:06Z","closed_at":"2026-04-09T06:38:06Z","close_reason":"Added Model::regular_category_names() that filters out virtual categories. Updated CommitFormula and ImportPipeline::build_model to use it. Regression test confirms empty model shows 'Add at least one category first.'","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-aa9","title":"Test audit: import/wizard.rs at 60% coverage","description":"import/wizard.rs has 60% line coverage with significant untested paths in the ImportPipeline and ImportWizard. Pipeline creation, schema inference, and complex nested JSON structures need more test coverage.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:43Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:53:16Z","closed_at":"2026-04-09T05:53:16Z","close_reason":"Added 23 wizard tests covering step transitions, cursor movement, proposal toggle/cycle, formula lifecycle, date config, preview summary, sample formulas, and edge cases. Coverage from 60% to 94%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-2kg","title":"Test audit: ui/effect.rs at 18% coverage","description":"ui/effect.rs has 50+ effect types but only 18% line coverage. Most effects are thin apply() methods tested indirectly through app.rs integration tests. Complex effects like drill reconciliation and import deserve targeted unit tests per the testing guidelines.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:36:48Z","closed_at":"2026-04-09T05:36:48Z","close_reason":"Added 41 direct effect tests covering model mutations, view navigation stacks, drill state (value edit, coord rename, clear), panels, toggles, search buffer special case, and more. Coverage from 18% to 75%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-c02","title":"Test audit: command/cmd.rs at 52% coverage","description":"command/cmd.rs has 40+ commands but only 52% line coverage. Commands are pure functions (receive \u0026CmdContext, return Vec\u003cEffect\u003e) so they're easy to test. Many commands like add-category, add-item, set-cell, transpose, drill, export, and various panel commands lack tests. This is the highest bang-for-buck coverage improvement.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:01Z","created_by":"Edward Langley","updated_at":"2026-04-09T05:28:07Z","closed_at":"2026-04-09T05:28:07Z","close_reason":"Added 53 tests. Coverage from 52% to 75%. Also found bug: CommitFormula targets virtual categories on empty models (filed as improvise-79u).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-abz","title":"2.5 Verify all Phase 2 artifacts exist and are committed","description":"Confirm: README.md with all 10 sections, docs/demo.gif under 5MB referenced from README, docs/demo.tape regenerates GIF, all 4 .cast files exist, example files exist with synthetic data only, flake.nix includes asciinema/vhs/cargo-dist, nix develop succeeds.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:48Z","created_by":"Edward Langley","updated_at":"2026-04-09T22:24:08Z","closed_at":"2026-04-09T22:24:08Z","close_reason":"All Phase 2 artifacts verified: README, demo.gif (791KB), demo.tape, 4 casts at 120x37, example files, flake tooling, nix develop works","dependencies":[{"issue_id":"improvise-abz","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-abz","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T21:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-d4w","title":"2.4 Record asciinema casts","description":"Record four .cast files under docs/casts/: import.cast (CSV import wizard), pivot.cast (axis reassignment), drill.cast (drill into aggregated cell), formulas.cast (add Profit formula). Terminal: 100x30. Use -i 2 flag. Each under 60 seconds. Add recording helper script.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:47Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:20:50Z","closed_at":"2026-04-09T08:20:50Z","close_reason":"All four casts recorded: pivot, drill, formulas, import.","dependencies":[{"issue_id":"improvise-d4w","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-d4w","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-odx","title":"2.3 Create docs/demo.tape and generate docs/demo.gif","description":"Write VHS .tape file scripting a ~20-second pivot reassignment demo. Generate GIF via nix develop --command vhs docs/demo.tape. Must be under 5MB, show start in pivot view -\u003e T -\u003e axis reassigns -\u003e T -\u003e axis reassigns again. Iterate until readable.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T08:03:49Z","closed_at":"2026-04-09T08:03:49Z","close_reason":"docs/demo.tape and docs/demo.gif created, LFS-tracked, wired into README.","dependencies":[{"issue_id":"improvise-odx","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T21:09:21Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-odx","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T21:09:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:02:52Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:30:19Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:37Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:34Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-rml","title":"Consolidate Axis display into a method on Axis","description":"Axis display logic is duplicated:\\n- src/ui/tile_bar.rs:30-36 — axis_display() match (4 arms)\\n- src/ui/category_panel.rs:14-19 — exact copy of the same match\\n- src/ui/view_panel.rs:40-44 — inline match\\n- src/command/cmd/tile.rs:112, 131, 143 — axis cycling and conversion matches\\n\\nAdd methods on Axis (crates/improvise-core/src/view/axis.rs):\\n- display_short() -\u003e (\u0026'static str, Color)\\n- display_long() -\u003e \u0026'static str\\n- next() -\u003e Self (cycle Row -\u003e Col -\u003e Page -\u003e None -\u003e Row)\\n- is_data_axis() -\u003e bool\\n\\nCorrectness win: eliminates the tile_bar/category_panel copy-paste — currently a change to axis colors or short labels must be made in both places.\\n\\n~25 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:21Z","created_by":"Ed L","updated_at":"2026-04-16T19:02:21Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:14Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:13Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-669","title":"Extract axis pruning helper in view/layout.rs","description":"crates/improvise-core/src/view/layout.rs:245-262 (rows) and :265-284 (cols) contain near-identical pruning loops that track data_idx separately from iteration order against a keep_row/keep_col bool vector. The data_idx tracking was flagged as brittle in the deep review.\\n\\nAbstraction: fn prune_axis_items(items: \u0026mut Vec\u003cAxisEntry\u003e, keep: \u0026[bool]) -\u003e Vec\u003cAxisEntry\u003e\\n\\nCorrectness win: centralizes the invariant (one place to assert data_idx == keep.len() at loop exit). A bug in the row prune can't hide while the col prune stays correct. ~20 LOC collapsed.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:12Z","created_by":"Ed L","updated_at":"2026-04-16T18:54:12Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-aaw","title":"Replace CreateAndSwitchView with Binding::Sequence","description":"command/cmd/panel.rs:546-560 bundles create-view + switch-view + change-mode. Both primitives already exist via effect_cmds.\\n\\nFix: bind Sequence [(create-view nix-shell-env), (switch-view nix-shell-env), (enter-mode normal)] in the keymap. If the auto-generated view name is load-bearing, either thread through a minibuffer (matches other add flows) or keep a single create-view primitive that both creates and switches by default.\\n\\n~12 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:54Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:54Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-git","title":"Replace SaveAndQuit with Binding::Sequence in keymap","description":"command/cmd/mode.rs:232-241 is an 8-line command chaining Save + ChangeMode(Quit). Both primitives already exist.\\n\\nFix: delete the struct; bind via Sequence [(w, []), (force-quit, [])] in the keymap.\\n\\nTrivial application of the 'if the keybinding is A then B, the binding should be a Sequence, not a command named AAndB' principle.\\n\\n~8 LOC.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:48Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-s13","title":"Delete mode-entry shim commands; use enter-mode with arg","description":"command/cmd/mode.rs:311-319 has EnterExportPrompt and similar shims (EnterSearchMode, EnterTileSelect, EnterFormulaEdit) that are each ~9-line wrappers around change_mode(AppMode::foo()). The generic EnterMode already exists with parse_mode_name() in the registry.\\n\\nFix: delete the shims; bind 'enter-mode' with the mode name as an arg in the keymap.\\n\\nCorrectness win: one code path for mode transitions. A new invariant on mode enter (e.g., 'always rebuild layout') needs to be added in one place instead of N shims.\\n\\n~40 LOC across all shims.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:53:44Z","created_by":"Ed L","updated_at":"2026-04-16T18:53:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-7yx","title":"Close test gaps flagged by deep review","description":"Deep review identified several test-coverage gaps worth closing:\\n\\n1. Keymap fallback chain: command/keymap.rs has 22 tests but no case for 'transient keymap exists but key does not match' (app.rs:396-406).\\n2. Ambiguous date detection: import/analyzer.rs:49-77 first-match heuristic not tested against ['01/02/2025', '02/03/2025'] style ambiguity.\\n3. CSV adversarial inputs: no tests for BOM, CRLF, empty file, single-column file.\\n4. Records missing a category field mid-import: wizard.rs:160-229 has the valid=false/break path but it is not covered by build_model_cells_match_source_data or similar.\\n5. Gzip corruption error path: save_and_load_roundtrip_gzip tests happy path only; add a test that load() on a truncated .gz file surfaces an error.\\n\\nLow-effort, high-value additions. Batch into a single test-coverage PR.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:19:07Z","created_by":"Ed L","updated_at":"2026-04-16T18:19:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-61f","title":"Add invariant test for command registry name mapping","description":"src/command/cmd/registry.rs is 586 lines with 0 tests. Each registration manually duplicates the command name as a string alongside the Cmd::name() implementation on the struct. Nothing verifies they match. Fix: add a test that iterates all registered entries and asserts registry_name == command.name() after construction. Low effort, locks down a class of typo bugs.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:55Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:55Z","dependencies":[{"issue_id":"improvise-61f","depends_on_id":"improvise-9cn","type":"blocks","created_at":"2026-04-16T11:54:34Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-nr5","title":"Split persistence/mod.rs into focused sub-modules","description":"crates/improvise-io/src/persistence/mod.rs is 2402 lines mixing four cohesive units: pipe quoting (lines ~20-82), format_md (~155-282), parse_md (~285-580), export_csv (~581-630). Split into persistence/{quoting,format,parse,export}.rs behind the existing pub facade; no public-API change. Improves navigability and makes adding new format features easier to review.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:52Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:52Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-04-16T18:18:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-04-16T05:45:49Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-04-14T07:53:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-04-14T07:52:57Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-04-14T07:53:21Z","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-04-14T07:53:20Z","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-04-14T07:39:41Z","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"Depends on improvise-djm (main-thread entry point) and the worker-server issue. Mostly configuration and glue — heavy lifting lives in the upstream wasm crates.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-04-14T07:39:31Z","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-cr3","title":"DOM renderer for browser grid (reads ViewState + render cache)","description":"JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a \u003ctable\u003e element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event.","design":"Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single \u003ctable\u003e for the grid body, \u003cthead\u003e for column labels, \u003ctbody\u003e with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected \u003ctd\u003e. Mode indicator is a \u003cdiv\u003e above the table. Minibuffer is a \u003cdiv\u003e shown conditionally when mode is Editing/FormulaEdit/etc.","acceptance_criteria":"(1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP.","notes":"Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:41Z","created_by":"spot","updated_at":"2026-04-14T07:53:24Z","dependencies":[{"issue_id":"improvise-cr3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cr3","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:42Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:01Z","dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:01Z","dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-04-11T07:49:57Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||||
|
{"id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:00Z","dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-04-14T07:39:46Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:03Z","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||||
|
{"id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:02Z","dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
|
||||||
|
{"id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","status":"open","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:02Z","dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"memory","key":"app-new-calls-recompute-formulas-before-building-initial","value":"App::new calls recompute_formulas before building initial layout so formula values appear on first frame. render() test helper also calls recompute_formulas."}
|
||||||
|
{"_type":"memory","key":"dynamic-measure-formula-targets-are-dynamically-included-via","value":"Dynamic _Measure: formula targets are dynamically included via Model::measure_item_names() and effective_item_names(). add_formula no longer adds items to _Measure category. find_item_category falls back to formula targets. CommitFormula defaults target_category to _Measure."}
|
||||||
|
{"_type":"memory","key":"gen-grammar-example-generates-random-valid-improv-content","value":"gen-grammar example: generates random valid .improv content from pest grammar rules. Uses word pools for realistic output. pretty-print example: parses stdin and prints formatted output. Both in examples/ directory."}
|
||||||
|
{"_type":"memory","key":"lib-rs-created-to-enable-examples-to-import","value":"lib.rs created to enable examples to import from improvise crate. main.rs uses 'use improvise::*' instead of mod declarations."}
|
||||||
|
{"_type":"memory","key":"compiler-exhaustiveness-theme","value":"Running theme across the 2026-04-16 refactor backlog: the compilers exhaustive-match check is being bypassed. (1) string compares against virtual-category names (improvise-2lh); (2) registered command names as strings vs Cmd::name() (improvise-9cn, improvise-61f); (3) minibuffer buffer_key strings threaded through 7 AppMode constructors vs command lookups (improvise-k8h); (4) AppMode per-variant logic scattered across 52 match sites in 8 files (improvise-2hi); (5) duplicated Axis display matches in tile_bar + category_panel (improvise-rml). Common fix: push dispatch onto a method on the enum/type so the exhaustive match has one home."}
|
||||||
|
{"_type":"memory","key":"drillintocell-strips-measure-coordinate-from-drill-key-when","value":"DrillIntoCell strips _Measure coordinate from drill key when it matches a formula target, so matching_cells finds raw data records instead of returning empty."}
|
||||||
|
{"_type":"memory","key":"persistence-index-and-dim-categories-are-never-written","value":"Persistence: _Index and _Dim categories are never written to .improv files. _Measure only persists non-formula items. Formulas targeting _Measure omit the [_Measure] suffix (it's the default). Parser defaults to _Measure when no [Category] suffix present."}
|
||||||
|
{"_type":"memory","key":"review-methodology-scoped-explore-agents","value":"Deep-review batches work best with narrowly-scoped Explore agents (one per layer/principle lens: model+formula, command+ui, persistence+import, LoD violations, OCP violations). Prompt each with: (1) specific files with sizes, (2) the lens/principle to apply, (3) exactly how to report (count + file:line refs, prioritized, ~800 words). Parallel launches worked cleanly when file scopes did not overlap. Session 2026-04-16 produced a ~20-issue backlog this way."}
|
||||||
|
{"_type":"memory","key":"agent-issue-drift-pattern","value":"Beads issues created by agents (owner 'spot') can duplicate users actively in-progress work. Example 2026-04-16: improvise-dwe (Split App into AppState + App wrapper) was a coarser restatement of improvise-vb4 (Split AppState into ModelState + ViewState) which was already in_progress and assigned to the user; dwe was filed 2 days later by an agent unaware of vb4. Before filing a structural refactor, run bd search for keywords from its core concept AND check in_progress issues."}
|
||||||
|
{"_type":"memory","key":"keymap-parent-inheritance-added-linked-list-of-keymaps","value":"Keymap parent inheritance added (linked list of keymaps). All minibuffer modes use Binding::Sequence for Enter and Esc to include clear-buffer command. ClearBufferCmd registered in registry."}
|
||||||
7
.beads/metadata.json
Normal file
7
.beads/metadata.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"database": "dolt",
|
||||||
|
"backend": "dolt",
|
||||||
|
"dolt_mode": "embedded",
|
||||||
|
"dolt_database": "improvise",
|
||||||
|
"project_id": "1ccea08a-5afb-4b57-acad-78282e9e3af6"
|
||||||
|
}
|
||||||
26
.claude/settings.json
Normal file
26
.claude/settings.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreCompact": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd prime",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"command": "bd prime",
|
||||||
|
"type": "command"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"matcher": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
0
.gitattributes
vendored
Normal file
0
.gitattributes
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
|
||||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||||
|
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||||
|
|
||||||
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||||
|
|
||||||
296
.github/workflows/release.yml
vendored
Normal file
296
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
|
||||||
|
#
|
||||||
|
# Copyright 2022-2024, axodotdev
|
||||||
|
# SPDX-License-Identifier: MIT or Apache-2.0
|
||||||
|
#
|
||||||
|
# CI that:
|
||||||
|
#
|
||||||
|
# * checks for a Git Tag that looks like a release
|
||||||
|
# * builds artifacts with dist (archives, installers, hashes)
|
||||||
|
# * uploads those artifacts to temporary workflow zip
|
||||||
|
# * on success, uploads the artifacts to a GitHub Release
|
||||||
|
#
|
||||||
|
# Note that the GitHub Release will be created with a generated
|
||||||
|
# title/body based on your changelogs.
|
||||||
|
|
||||||
|
name: Release
|
||||||
|
permissions:
|
||||||
|
"contents": "write"
|
||||||
|
|
||||||
|
# This task will run whenever you push a git tag that looks like a version
|
||||||
|
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
|
||||||
|
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
|
||||||
|
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
|
||||||
|
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
|
||||||
|
#
|
||||||
|
# If PACKAGE_NAME is specified, then the announcement will be for that
|
||||||
|
# package (erroring out if it doesn't have the given version or isn't dist-able).
|
||||||
|
#
|
||||||
|
# If PACKAGE_NAME isn't specified, then the announcement will be for all
|
||||||
|
# (dist-able) packages in the workspace with that version (this mode is
|
||||||
|
# intended for workspaces with only one dist-able package, or with all dist-able
|
||||||
|
# packages versioned/released in lockstep).
|
||||||
|
#
|
||||||
|
# If you push multiple tags at once, separate instances of this workflow will
|
||||||
|
# spin up, creating an independent announcement for each one. However, GitHub
|
||||||
|
# will hard limit this to 3 tags per commit, as it will assume more tags is a
|
||||||
|
# mistake.
|
||||||
|
#
|
||||||
|
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||||
|
# will be marked as a prerelease.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||||
|
plan:
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
outputs:
|
||||||
|
val: ${{ steps.plan.outputs.manifest }}
|
||||||
|
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
|
||||||
|
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
|
||||||
|
publishing: ${{ !github.event.pull_request }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install dist
|
||||||
|
# we specify bash to get pipefail; it guards against the `curl` command
|
||||||
|
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||||
|
shell: bash
|
||||||
|
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh"
|
||||||
|
- name: Cache dist
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/dist
|
||||||
|
# sure would be cool if github gave us proper conditionals...
|
||||||
|
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
|
||||||
|
# functionality based on whether this is a pull_request, and whether it's from a fork.
|
||||||
|
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
|
||||||
|
# but also really annoying to build CI around when it needs secrets to work right.)
|
||||||
|
- id: plan
|
||||||
|
run: |
|
||||||
|
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
cat plan-dist-manifest.json
|
||||||
|
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: "Upload dist-manifest.json"
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: artifacts-plan-dist-manifest
|
||||||
|
path: plan-dist-manifest.json
|
||||||
|
|
||||||
|
# Build and packages all the platform-specific things
|
||||||
|
build-local-artifacts:
|
||||||
|
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
|
||||||
|
# Let the initial task tell us to not run (currently very blunt)
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
# Target platforms/runners are computed by dist in create-release.
|
||||||
|
# Each member of the matrix has the following arguments:
|
||||||
|
#
|
||||||
|
# - runner: the github runner
|
||||||
|
# - dist-args: cli flags to pass to dist
|
||||||
|
# - install-dist: expression to run to install dist on the runner
|
||||||
|
#
|
||||||
|
# Typically there will be:
|
||||||
|
# - 1 "global" task that builds universal installers
|
||||||
|
# - N "local" tasks that build each platform's binaries and platform-specific installers
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
container: ${{ matrix.container && matrix.container.image || null }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
|
||||||
|
steps:
|
||||||
|
- name: enable windows longpaths
|
||||||
|
run: |
|
||||||
|
git config --global core.longpaths true
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install Rust non-interactively if not already installed
|
||||||
|
if: ${{ matrix.container }}
|
||||||
|
run: |
|
||||||
|
if ! command -v cargo > /dev/null 2>&1; then
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
- name: Install dist
|
||||||
|
run: ${{ matrix.install_dist.run }}
|
||||||
|
# Get the dist-manifest
|
||||||
|
- name: Fetch local artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
${{ matrix.packages_install }}
|
||||||
|
- name: Build artifacts
|
||||||
|
run: |
|
||||||
|
# Actually do builds and make zips and whatnot
|
||||||
|
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
- id: cargo-dist
|
||||||
|
name: Post-build
|
||||||
|
# We force bash here just because github makes it really hard to get values up
|
||||||
|
# to "real" actions without writing to env-vars, and writing to env-vars has
|
||||||
|
# inconsistent syntax between shell and powershell.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Parse out what we just built and upload it to scratch storage
|
||||||
|
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
|
- name: "Upload artifacts"
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||||
|
path: |
|
||||||
|
${{ steps.cargo-dist.outputs.paths }}
|
||||||
|
${{ env.BUILD_MANIFEST_NAME }}
|
||||||
|
|
||||||
|
# Build and package all the platform-agnostic(ish) things
|
||||||
|
build-global-artifacts:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- build-local-artifacts
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install cached dist
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/
|
||||||
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
|
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||||
|
- name: Fetch local artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- id: cargo-dist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
|
||||||
|
echo "dist ran successfully"
|
||||||
|
|
||||||
|
# Parse out what we just built and upload it to scratch storage
|
||||||
|
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
|
||||||
|
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||||
|
- name: "Upload artifacts"
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: artifacts-build-global
|
||||||
|
path: |
|
||||||
|
${{ steps.cargo-dist.outputs.paths }}
|
||||||
|
${{ env.BUILD_MANIFEST_NAME }}
|
||||||
|
# Determines if we should publish/announce
|
||||||
|
host:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- build-local-artifacts
|
||||||
|
- build-global-artifacts
|
||||||
|
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||||
|
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
outputs:
|
||||||
|
val: ${{ steps.host.outputs.manifest }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
|
- name: Install cached dist
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
name: cargo-dist-cache
|
||||||
|
path: ~/.cargo/bin/
|
||||||
|
- run: chmod +x ~/.cargo/bin/dist
|
||||||
|
# Fetch artifacts from scratch-storage
|
||||||
|
- name: Fetch artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: target/distrib/
|
||||||
|
merge-multiple: true
|
||||||
|
- id: host
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
|
||||||
|
echo "artifacts uploaded and released successfully"
|
||||||
|
cat dist-manifest.json
|
||||||
|
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: "Upload dist-manifest.json"
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
# Overwrite the previous copy
|
||||||
|
name: artifacts-dist-manifest
|
||||||
|
path: dist-manifest.json
|
||||||
|
# Create a GitHub Release while uploading all files to it
|
||||||
|
- name: "Download GitHub Artifacts"
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
pattern: artifacts-*
|
||||||
|
path: artifacts
|
||||||
|
merge-multiple: true
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
# Remove the granular manifests
|
||||||
|
rm -f artifacts/*-dist-manifest.json
|
||||||
|
- name: Create GitHub Release
|
||||||
|
env:
|
||||||
|
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
|
||||||
|
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
|
||||||
|
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
|
||||||
|
RELEASE_COMMIT: "${{ github.sha }}"
|
||||||
|
run: |
|
||||||
|
# Write and read notes from a file to avoid quoting breaking things
|
||||||
|
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
|
||||||
|
|
||||||
|
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||||
|
|
||||||
|
announce:
|
||||||
|
needs:
|
||||||
|
- plan
|
||||||
|
- host
|
||||||
|
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||||
|
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||||
|
# "host" however must run to completion, no skipping allowed!
|
||||||
|
if: ${{ always() && needs.host.result == 'success' }}
|
||||||
|
runs-on: "ubuntu-22.04"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
submodules: recursive
|
||||||
23
.gitignore
vendored
23
.gitignore
vendored
@ -3,3 +3,26 @@ target/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/result
|
/result
|
||||||
.direnv
|
.direnv
|
||||||
|
[#]*
|
||||||
|
symbols.json
|
||||||
|
profile.json
|
||||||
|
profile.json.gz
|
||||||
|
bench/*.txt
|
||||||
|
|
||||||
|
# Added by git-smart-commit
|
||||||
|
*.patch
|
||||||
|
*.improv
|
||||||
|
!examples/*.improv
|
||||||
|
|
||||||
|
# Beads / Dolt files (added by bd init)
|
||||||
|
.dolt/
|
||||||
|
*.db
|
||||||
|
.beads-credential-key
|
||||||
|
|
||||||
|
# Added by git-smart-commit
|
||||||
|
*.swp
|
||||||
|
proptest-regressions/
|
||||||
|
.cursor
|
||||||
|
.claude/worktrees/
|
||||||
|
*~
|
||||||
|
/roadmap.html
|
||||||
|
|||||||
95
AGENTS.md
Normal file
95
AGENTS.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
- Always use tests to demonstrate the existence of a bug before fixing the bug.
|
||||||
|
- If you suspect that a bug exists, use a test to demonstrate it first:
|
||||||
|
- prefer unit tests testing a small amount of code to integration or e2e tests
|
||||||
|
- Option<...> or Result<...> are fine but should not be present in the majority of the code.
|
||||||
|
- Similarly, code managing Box<...> or RC<...>, etc. for containers pointing to heap data should be split
|
||||||
|
from logic
|
||||||
|
- @context/repo-map.md is your "road map" for the repository. use it to reduce exploration and keep it updated.
|
||||||
|
- @context/design-principles.md is also important for keeping the repository consistent.
|
||||||
|
- prefer merges to rebasing.
|
||||||
|
- always start responses with bananaS!
|
||||||
|
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --claim # Claim work atomically
|
||||||
|
bd close <id> # Complete work
|
||||||
|
bd dolt push # Push beads data to remote
|
||||||
|
```
|
||||||
|
|
||||||
|
## Non-Interactive Shell Commands
|
||||||
|
|
||||||
|
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
|
||||||
|
|
||||||
|
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
|
||||||
|
|
||||||
|
**Use these forms instead:**
|
||||||
|
```bash
|
||||||
|
# Force overwrite without prompting
|
||||||
|
cp -f source dest # NOT: cp source dest
|
||||||
|
mv -f source dest # NOT: mv source dest
|
||||||
|
rm -f file # NOT: rm file
|
||||||
|
|
||||||
|
# For recursive operations
|
||||||
|
rm -rf directory # NOT: rm -r directory
|
||||||
|
cp -rf source dest # NOT: cp -r source dest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Other commands that may prompt:**
|
||||||
|
- `scp` - use `-o BatchMode=yes` for non-interactive
|
||||||
|
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
|
||||||
|
- `apt-get` - use `-y` flag
|
||||||
|
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
|
||||||
|
|
||||||
|
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||||
|
## Beads Issue Tracker
|
||||||
|
|
||||||
|
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --claim # Claim work
|
||||||
|
bd close <id> # Complete work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||||
|
- Run `bd prime` for detailed command reference and session close protocol
|
||||||
|
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||||
|
|
||||||
|
## Session Completion
|
||||||
|
|
||||||
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||||
|
|
||||||
|
**MANDATORY WORKFLOW:**
|
||||||
|
|
||||||
|
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||||
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||||
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||||
|
```bash
|
||||||
|
git pull --rebase
|
||||||
|
bd dolt push
|
||||||
|
git push
|
||||||
|
git status # MUST show "up to date with origin"
|
||||||
|
```
|
||||||
|
5. **Clean up** - Clear stashes, prune remote branches
|
||||||
|
6. **Verify** - All changes committed AND pushed
|
||||||
|
7. **Hand off** - Provide context for next session
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
- Work is NOT complete until `git push` succeeds
|
||||||
|
- NEVER stop before pushing - that leaves work stranded locally
|
||||||
|
- NEVER say "ready to push when you are" - YOU must push
|
||||||
|
- If push fails, resolve and retry until it succeeds
|
||||||
|
<!-- END BEADS INTEGRATION -->
|
||||||
54
CLAUDE.md
54
CLAUDE.md
@ -1,6 +1,48 @@
|
|||||||
- Always use tests to demonstrate the existence of a bug before fixing the bug.
|
- see @AGENTS.md
|
||||||
- If you suspect that a bug exists, use a test to demonstrate it first:
|
|
||||||
- prefer unit tests testing a small amount of code to integration or e2e tests
|
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||||
- Option<...> or Result<...> are fine but should not be present in the majority of the code.
|
## Beads Issue Tracker
|
||||||
- Similarly, code managing Box<...> or RC<...>, etc. for containers pointing to heap data should be split
|
|
||||||
from logic
|
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||||
|
|
||||||
|
### Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bd ready # Find available work
|
||||||
|
bd show <id> # View issue details
|
||||||
|
bd update <id> --claim # Claim work
|
||||||
|
bd close <id> # Complete work
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||||
|
- Run `bd prime` for detailed command reference and session close protocol
|
||||||
|
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||||
|
|
||||||
|
## Session Completion
|
||||||
|
|
||||||
|
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||||
|
|
||||||
|
**MANDATORY WORKFLOW:**
|
||||||
|
|
||||||
|
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||||
|
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||||
|
3. **Update issue status** - Close finished work, update in-progress items
|
||||||
|
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||||
|
```bash
|
||||||
|
git merge origin/main
|
||||||
|
bd dolt push
|
||||||
|
git push
|
||||||
|
git status # MUST show "up to date with origin"
|
||||||
|
```
|
||||||
|
5. **Clean up** - Clear stashes, prune remote branches
|
||||||
|
6. **Verify** - All changes committed AND pushed
|
||||||
|
7. **Hand off** - Provide context for next session
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
- Work is NOT complete until `git push` succeeds
|
||||||
|
- NEVER stop before pushing - that leaves work stranded locally
|
||||||
|
- NEVER say "ready to push when you are" - YOU must push
|
||||||
|
- If push fails, resolve and retry until it succeeds
|
||||||
|
<!-- END BEADS INTEGRATION -->
|
||||||
|
|||||||
1394
Cargo.lock
generated
1394
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
47
Cargo.toml
47
Cargo.toml
@ -1,32 +1,61 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [".", "crates/improvise-formula", "crates/improvise-core", "crates/improvise-io"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "improvise"
|
name = "improvise"
|
||||||
version = "0.1.0"
|
version = "0.1.0-rc2"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
description = "Multi-dimensional data modeling terminal application"
|
description = "Terminal pivot-table modeling in the spirit of Lotus Improv"
|
||||||
license = "MIT"
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||||
|
homepage = "https://github.com/fiddlerwoaroof/improvise"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["tui", "pivot", "spreadsheet", "data", "improv"]
|
||||||
|
categories = ["command-line-utilities", "visualization"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "improvise"
|
name = "improvise"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
improvise-core = { path = "crates/improvise-core" }
|
||||||
crossterm = "0.28"
|
improvise-formula = { path = "crates/improvise-formula" }
|
||||||
|
improvise-io = { path = "crates/improvise-io" }
|
||||||
|
ratatui = "0.30"
|
||||||
|
crossterm = "0.29"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "1"
|
thiserror = "2"
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
unicode-width = "0.2"
|
unicode-width = "^0.2"
|
||||||
dirs = "5"
|
dirs = "6"
|
||||||
|
csv = "1"
|
||||||
|
clap = { version = "4.6.0", features = ["derive"] }
|
||||||
|
enum_dispatch = "0.3.13"
|
||||||
|
pest = "2.8.6"
|
||||||
|
pest_derive = "2.8.6"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
pest = "2.8.6"
|
||||||
|
pest_derive = "2.8.6"
|
||||||
|
pest_meta = "2.8.6"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
[profile.profiling]
|
||||||
|
inherits = "release"
|
||||||
|
strip = false
|
||||||
|
debug = 2
|
||||||
|
|
||||||
|
# The profile that 'dist' will build with
|
||||||
|
[profile.dist]
|
||||||
|
inherits = "release"
|
||||||
|
|||||||
202
LICENSE
Normal file
202
LICENSE
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
200
README.md
Normal file
200
README.md
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# improvise
|
||||||
|
|
||||||
|
*Terminal pivot-table modeling in the spirit of Lotus Improv — multidimensional cells, formulas over dimensions instead of cell addresses, and vim-style keybindings for reassigning axes on the fly.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Lotus Improv (NeXT, 1991) separated data from its presentation: cells were
|
||||||
|
addressed by named dimensions, not grid coordinates, and rearranging a view
|
||||||
|
didn't break formulas. That idea never made it into mainstream tools. Excel
|
||||||
|
pivot tables borrowed the visual rearrangement but kept cell-address formulas.
|
||||||
|
Terminal data tools like sc-im and VisiData do different things well — sc-im is
|
||||||
|
a traditional spreadsheet, VisiData is a data explorer — but neither offers the
|
||||||
|
dimension-keyed data model that made Improv interesting. improvise is a small
|
||||||
|
attempt to bring that model to a modern terminal, with a formula language that
|
||||||
|
references category and item names, views that can be rearranged with a single
|
||||||
|
keystroke, and a plain-text file format you can diff in git.
|
||||||
|
|
||||||
|
See [docs/design-notes.md](docs/design-notes.md) for the original product
|
||||||
|
vision and non-goals.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .
|
||||||
|
./result/bin/improvise examples/demo.improv
|
||||||
|
```
|
||||||
|
|
||||||
|
Or import your own CSV:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./result/bin/improvise import path/to/data.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
The included `examples/demo.improv` was generated from `examples/demo.csv`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
improvise import examples/demo.csv \
|
||||||
|
--no-wizard \
|
||||||
|
--category Region --category Product --category Customer \
|
||||||
|
--measure Revenue --measure Cost \
|
||||||
|
--time Date --extract Date:Month \
|
||||||
|
--axis Region:row --axis Product:row \
|
||||||
|
--axis Date_Month:column --axis Measure:column \
|
||||||
|
--axis Customer:page --axis Date:none \
|
||||||
|
--formula "Profit = Revenue - Cost" \
|
||||||
|
--name "Acme Sales Demo" \
|
||||||
|
-o examples/demo.improv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key bindings to try first
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `T` | Enter tile mode — reassign category axes |
|
||||||
|
| `[` / `]` | Cycle through page-axis items |
|
||||||
|
| `>` | Drill into an aggregated cell |
|
||||||
|
| `<` | Return from drill-down |
|
||||||
|
| `F` | Open the formula panel |
|
||||||
|
| `t` | Transpose rows and columns |
|
||||||
|
| `?` / `F1` | Full key reference |
|
||||||
|
| `:w` | Save |
|
||||||
|
| `:q` | Quit |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### With Nix (preferred)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix build .
|
||||||
|
# or install into your profile:
|
||||||
|
nix profile install .
|
||||||
|
```
|
||||||
|
|
||||||
|
### From crates.io
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install improvise
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prebuilt binaries
|
||||||
|
|
||||||
|
See the [GitHub releases page](https://github.com/fiddlerwoaroof/improvise/releases)
|
||||||
|
for prebuilt binaries (Linux x86_64, macOS Intel and Apple Silicon).
|
||||||
|
|
||||||
|
## The data model
|
||||||
|
|
||||||
|
Every cell lives at the intersection of named categories and items —
|
||||||
|
`(Region=East, Measure=Revenue)` — not at a grid address like `B3`. A model
|
||||||
|
can have up to 12 categories, each with an ordered list of items that can be
|
||||||
|
organized into collapsible groups (e.g. months grouped into quarters).
|
||||||
|
|
||||||
|
Formulas reference dimension names, not cell addresses:
|
||||||
|
|
||||||
|
```
|
||||||
|
Profit = Revenue - Cost
|
||||||
|
Tax = Revenue * 0.08
|
||||||
|
Margin = IF(Revenue > 0, Profit / Revenue, 0)
|
||||||
|
BigDeal = Sum(Revenue WHERE Region = "West")
|
||||||
|
```
|
||||||
|
|
||||||
|
The formula language supports `+` `-` `*` `/` `^`, comparisons, `IF`, and
|
||||||
|
aggregation functions (`Sum`, `Avg`, `Min`, `Max`, `Count`) with optional
|
||||||
|
`WHERE` clauses. Formulas apply uniformly across all intersections — no
|
||||||
|
copying, no dragging, no `$A$1` anchoring.
|
||||||
|
|
||||||
|
## Views and axes
|
||||||
|
|
||||||
|
A view assigns each category to one of four axes: **row**, **column**,
|
||||||
|
**page** (slicer), or **hidden**. The grid layout is a pure function of
|
||||||
|
`(Model, View)` → `GridLayout` — transposing is just swapping row and column
|
||||||
|
assignments, and it happens in one keystroke (`t`). Page-axis categories act
|
||||||
|
as filters: `[` and `]` cycle through items.
|
||||||
|
|
||||||
|
Press `T` to enter tile mode, where each category appears as a tile in the
|
||||||
|
tile bar. Move between tiles with `h`/`l`, then press `Space` to cycle axes
|
||||||
|
or `r`/`c`/`p` to set one directly.
|
||||||
|
|
||||||
|
Records mode (`R`) flips to a long-format view by assigning the virtual
|
||||||
|
`_Index` and `_Dim` categories to row and column axes. Drill-down (`>`) on an
|
||||||
|
aggregated cell captures a snapshot; edits accumulate in a staging area and
|
||||||
|
commit atomically on exit.
|
||||||
|
|
||||||
|
## File format
|
||||||
|
|
||||||
|
Models persist to a plain-text `.improv` format that reads like markdown and
|
||||||
|
diffs cleanly in git:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Sales 2025
|
||||||
|
|
||||||
|
## Category: Region
|
||||||
|
- North
|
||||||
|
- South
|
||||||
|
- East [Coastal]
|
||||||
|
- West [Coastal]
|
||||||
|
> Coastal
|
||||||
|
|
||||||
|
## Category: Measure
|
||||||
|
- Revenue
|
||||||
|
- Cost
|
||||||
|
- Profit
|
||||||
|
|
||||||
|
## Formulas
|
||||||
|
- Profit = Revenue - Cost [Measure]
|
||||||
|
|
||||||
|
## Data
|
||||||
|
Region=East, Measure=Revenue = 1200
|
||||||
|
Region=East, Measure=Cost = 800
|
||||||
|
|
||||||
|
## View: Default (active)
|
||||||
|
Region: row
|
||||||
|
Measure: column
|
||||||
|
format: ,.2f
|
||||||
|
```
|
||||||
|
|
||||||
|
Gzip-compressed `.improv.gz` is also supported. Legacy JSON is auto-detected
|
||||||
|
for backward compatibility.
|
||||||
|
|
||||||
|
## Import and scripting
|
||||||
|
|
||||||
|
The import wizard analyzes CSV columns and proposes each as a category
|
||||||
|
(string-valued), measure (numeric), time dimension (date-like, with optional
|
||||||
|
year/month/quarter extraction), or skip. Multiple CSVs merge automatically
|
||||||
|
with a synthetic "File" category.
|
||||||
|
|
||||||
|
All model mutations go through a typed command registry, so the same
|
||||||
|
operations that back the TUI work headless:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# single commands
|
||||||
|
improvise cmd 'add-cat Region' 'add-item Region East' -f model.improv
|
||||||
|
|
||||||
|
# script file (one command per line, # comments)
|
||||||
|
improvise script setup.txt -f model.improv
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's interesting about the architecture
|
||||||
|
|
||||||
|
All user actions flow through a two-phase command/effect pipeline. Commands
|
||||||
|
are pure functions: they receive an immutable `CmdContext` (model, layout,
|
||||||
|
cursor position, mode) and return a list of effects. Effects are the only
|
||||||
|
things that mutate app state, and each one is a small, debuggable struct.
|
||||||
|
This means commands are testable without a terminal, effects can be logged or
|
||||||
|
replayed, and the 40+ commands and 50+ effect types are all polymorphic —
|
||||||
|
dispatched through trait objects and a registry, not a central match block.
|
||||||
|
The keybinding system gives each of the 14 modes its own keymap, with
|
||||||
|
Emacs-style prefix keys for multi-stroke sequences.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
|
||||||
|
improvise is a personal project I built for my own use. I'm sharing it because
|
||||||
|
other people might find it useful, but I can't promise active maintenance or
|
||||||
|
feature development. Issues and PRs are welcome but may not get a fast
|
||||||
|
response. If you want to build on it, fork away.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0
|
||||||
1186
bank-info.improv
Normal file
1186
bank-info.improv
Normal file
File diff suppressed because it is too large
Load Diff
83
bench/gen_workload.py
Normal file
83
bench/gen_workload.py
Normal 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")
|
||||||
283
context/SPEC.md
283
context/SPEC.md
@ -1,283 +0,0 @@
|
|||||||
# Improvise — Multi-Dimensional Data Modeling Terminal Application
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Traditional spreadsheets conflate data, formulas, and presentation into a single flat grid addressed by opaque
|
|
||||||
cell references (A1, B7). This makes models fragile, hard to audit, and impossible to rearrange without
|
|
||||||
rewriting formulas. We are building a terminal application that treats data as a multi-dimensional,
|
|
||||||
semantically labeled structure — separating data, computation, and views into independent layers. The result
|
|
||||||
is a tool where formulas reference meaningful names, views can be rearranged instantly, and the same dataset
|
|
||||||
can be explored from multiple perspectives simultaneously.
|
|
||||||
|
|
||||||
The application compiles to a single static binary (`x86_64-unknown-linux-musl`) and provides a rich TUI
|
|
||||||
experience.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Core Data Model
|
|
||||||
|
|
||||||
### 1.1 Categories and Items
|
|
||||||
- Data is organized into **categories** (dimensions) and **items** (members of a dimension).
|
|
||||||
- Example: Category "Region" contains items "North", "South", "East", "West".
|
|
||||||
- Example: Category "Time" contains items "Q1", "Q2", "Q3", "Q4".
|
|
||||||
- Items within a category can be organized into **groups** forming a hierarchy.
|
|
||||||
- Example: Items "Jan", "Feb", "Mar" grouped under "Q1"; quarters grouped under "2025".
|
|
||||||
- Groups are collapsible/expandable for drill-down.
|
|
||||||
- A model supports up to **12 categories**.
|
|
||||||
|
|
||||||
### 1.2 Data Cells
|
|
||||||
- Each data cell is identified by the intersection of one item from each active category — not by grid coordinates.
|
|
||||||
- Cells hold numeric values, text, or empty/null.
|
|
||||||
- The underlying storage is a sparse multi-dimensional array (`HashMap<CellKey, CellValue>`).
|
|
||||||
|
|
||||||
### 1.3 Models
|
|
||||||
- A **model** is the top-level container: it holds all categories, items, groups, data cells, formulas, and views.
|
|
||||||
- Models are saved to and loaded from a single `.improv` file (JSON format).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Formula System
|
|
||||||
|
|
||||||
### 2.1 Named Formulas
|
|
||||||
- Formulas reference categories and items by name, not by cell address.
|
|
||||||
- Example: `Profit = Revenue - Cost`
|
|
||||||
- Example: `Tax = Revenue * 0.08`
|
|
||||||
- Example: `Margin = Profit / Revenue`
|
|
||||||
- A formula applies uniformly across all intersections of the referenced categories. No copying or dragging.
|
|
||||||
|
|
||||||
### 2.2 Formula Panel
|
|
||||||
- Formulas are defined in a **dedicated formula panel**, separate from the data grid.
|
|
||||||
- All formulas are visible in one place for easy auditing.
|
|
||||||
- Formulas cannot be accidentally overwritten by data entry.
|
|
||||||
|
|
||||||
### 2.3 Scoped Formulas (WHERE clause)
|
|
||||||
- A formula can be scoped to a subset of items:
|
|
||||||
- Example: `Discount = 0.10 * Price WHERE Region = "West"`
|
|
||||||
|
|
||||||
### 2.4 Aggregation
|
|
||||||
- Built-in aggregation functions: `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`.
|
|
||||||
|
|
||||||
### 2.5 Formula Language
|
|
||||||
- Expression-based (not Turing-complete).
|
|
||||||
- Operators: `+`, `-`, `*`, `/`, `^`, unary `-`.
|
|
||||||
- Comparisons: `=`, `!=`, `<`, `>`, `<=`, `>=`.
|
|
||||||
- Conditionals: `IF(condition, then, else)`.
|
|
||||||
- `WHERE` clause for filtering: `SUM(Sales WHERE Region = "East")`.
|
|
||||||
- Parentheses for grouping.
|
|
||||||
- Literal numbers and quoted strings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. View System
|
|
||||||
|
|
||||||
### 3.1 Views as First-Class Objects
|
|
||||||
- A **view** is a named configuration specifying:
|
|
||||||
- Which categories are assigned to **rows**, **columns**, and **pages** (filters/slicers).
|
|
||||||
- Which items/groups are visible vs. hidden.
|
|
||||||
- Sort order (future).
|
|
||||||
- Number formatting.
|
|
||||||
- Multiple views can exist per model. Each is independent.
|
|
||||||
- Editing data in any view updates the underlying model; all other views reflect the change.
|
|
||||||
|
|
||||||
### 3.2 Category Tiles
|
|
||||||
- Each category is represented as a **tile** displayed in the tile bar.
|
|
||||||
- The user can move tiles between row, column, and page axes to instantly pivot/rearrange the view.
|
|
||||||
- Moving a tile triggers an instant recalculation and re-render of the grid.
|
|
||||||
|
|
||||||
### 3.3 Page Axis (Slicing)
|
|
||||||
- Categories assigned to the page axis act as filters.
|
|
||||||
- The user selects a single item from a paged category using `[` and `]`.
|
|
||||||
|
|
||||||
### 3.4 Collapsing and Expanding
|
|
||||||
- Groups can be collapsed/expanded per-view (future: keyboard shortcut in grid).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. JSON Import Wizard
|
|
||||||
|
|
||||||
### 4.1 Purpose
|
|
||||||
- Users can import arbitrary JSON files to bootstrap a model.
|
|
||||||
|
|
||||||
### 4.2 Wizard Flow (interactive TUI)
|
|
||||||
|
|
||||||
**Step 1: Preview** — Structural summary of the JSON.
|
|
||||||
|
|
||||||
**Step 2: Select Array Path** — If the JSON is not a flat array, the user selects which key path contains the primary record array.
|
|
||||||
|
|
||||||
**Step 3: Review Proposals** — Fields are analyzed and proposed as:
|
|
||||||
- Category (small number of distinct string values)
|
|
||||||
- Measure (numeric)
|
|
||||||
- Time Category (date-like strings)
|
|
||||||
- Label/Identifier (skip)
|
|
||||||
|
|
||||||
**Step 4: Name the Model** — User names the model and confirms.
|
|
||||||
|
|
||||||
### 4.3 Headless Import
|
|
||||||
```
|
|
||||||
improvise --cmd '{"op":"ImportJson","path":"data.json"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Terminal UI
|
|
||||||
|
|
||||||
### 5.1 Layout
|
|
||||||
```
|
|
||||||
+---------------------------------------------------------------+
|
|
||||||
| Improvise | Model: Sales 2025 [*] [F1 Help] [Ctrl+Q] |
|
|
||||||
+---------------------------------------------------------------+
|
|
||||||
| [Page: Region = East] |
|
|
||||||
| | Q1 | Q2 | Q3 | Q4 | |
|
|
||||||
|--------------+---------+---------+---------+---------+--------|
|
|
||||||
| Shirts | 1,200 | 1,450 | 1,100 | 1,800 | |
|
|
||||||
| Pants | 800 | 920 | 750 | 1,200 | |
|
|
||||||
| ... |
|
|
||||||
|--------------+---------+---------+---------+---------+--------|
|
|
||||||
| Total | 4,100 | 4,670 | 3,750 | 5,800 | |
|
|
||||||
+---------------------------------------------------------------+
|
|
||||||
| Tiles: [Time ↔] [Product ↕] [Region ☰] Ctrl+↑↓←→ tiles |
|
|
||||||
+---------------------------------------------------------------+
|
|
||||||
| NORMAL | Default | Ctrl+F:formulas Ctrl+C:categories ... |
|
|
||||||
+---------------------------------------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Panels
|
|
||||||
- **Grid panel** (main): Scrollable table of the current view.
|
|
||||||
- **Tile bar**: Category tiles with axis symbols. `Ctrl+Arrow` enters tile-select mode.
|
|
||||||
- **Formula panel**: `Ctrl+F` — list and edit formulas.
|
|
||||||
- **Category panel**: `Ctrl+C` — manage categories and axis assignments.
|
|
||||||
- **View panel**: `Ctrl+V` — switch, create, delete views.
|
|
||||||
- **Status bar**: Mode, active view name, keyboard hints.
|
|
||||||
|
|
||||||
### 5.3 Navigation and Editing
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| ↑↓←→ / hjkl | Move cursor |
|
|
||||||
| Enter | Edit cell |
|
|
||||||
| Esc | Cancel edit |
|
|
||||||
| Tab | Focus next open panel |
|
|
||||||
| / | Search |
|
|
||||||
| [ / ] | Page axis prev/next |
|
|
||||||
| Ctrl+Arrow | Tile select mode |
|
|
||||||
| Enter/Space (tile) | Cycle axis (Row→Col→Page) |
|
|
||||||
| r / c / p (tile) | Set axis directly |
|
|
||||||
| Ctrl+F | Toggle formula panel |
|
|
||||||
| Ctrl+C | Toggle category panel |
|
|
||||||
| Ctrl+V | Toggle view panel |
|
|
||||||
| Ctrl+S | Save |
|
|
||||||
| Ctrl+E | Export CSV |
|
|
||||||
| F1 | Help |
|
|
||||||
| Ctrl+Q | Quit |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Command Layer (Headless Mode)
|
|
||||||
|
|
||||||
All model mutations go through a typed command layer. This enables:
|
|
||||||
- Scripting without the TUI
|
|
||||||
- Replay / audit log
|
|
||||||
- Testing without rendering
|
|
||||||
|
|
||||||
### 6.1 Command Format
|
|
||||||
JSON object with an `op` field:
|
|
||||||
```json
|
|
||||||
{"op": "CommandName", ...args}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Available Commands
|
|
||||||
|
|
||||||
| op | Required fields | Description |
|
|
||||||
|----|-----------------|-------------|
|
|
||||||
| `AddCategory` | `name` | Add a category/dimension |
|
|
||||||
| `AddItem` | `category`, `item` | Add an item to a category |
|
|
||||||
| `AddItemInGroup` | `category`, `item`, `group` | Add an item in a named group |
|
|
||||||
| `SetCell` | `coords: [[cat,item],...]`, `number` or `text` | Set a cell value |
|
|
||||||
| `ClearCell` | `coords` | Clear a cell |
|
|
||||||
| `AddFormula` | `raw`, `target_category` | Add/replace a formula |
|
|
||||||
| `RemoveFormula` | `target` | Remove a formula by target name |
|
|
||||||
| `CreateView` | `name` | Create a new view |
|
|
||||||
| `DeleteView` | `name` | Delete a view |
|
|
||||||
| `SwitchView` | `name` | Switch the active view |
|
|
||||||
| `SetAxis` | `category`, `axis` (`"row"/"column"/"page"`) | Set category axis |
|
|
||||||
| `SetPageSelection` | `category`, `item` | Set page-axis filter |
|
|
||||||
| `ToggleGroup` | `category`, `group` | Toggle group collapse |
|
|
||||||
| `Save` | `path` | Save model to file |
|
|
||||||
| `Load` | `path` | Load model from file |
|
|
||||||
| `ExportCsv` | `path` | Export active view to CSV |
|
|
||||||
| `ImportJson` | `path`, `model_name?`, `array_path?` | Import JSON file |
|
|
||||||
|
|
||||||
### 6.3 Response Format
|
|
||||||
```json
|
|
||||||
{"ok": true, "message": "optional message"}
|
|
||||||
{"ok": false, "message": "error description"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 Invocation
|
|
||||||
```bash
|
|
||||||
# Single command
|
|
||||||
improvise model.improv --cmd '{"op":"SetCell","coords":[["Region","East"],["Measure","Revenue"]],"number":1200}'
|
|
||||||
|
|
||||||
# Script file (one JSON object per line, # comments allowed)
|
|
||||||
improvise model.improv --script setup.jsonl
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Persistence
|
|
||||||
|
|
||||||
### 7.1 File Format
|
|
||||||
Native format: JSON-based `.improv` file containing all categories, items, groups, data cells, formulas, and view definitions.
|
|
||||||
|
|
||||||
Compressed variant: `.improv.gz` (gzip, same JSON payload).
|
|
||||||
|
|
||||||
### 7.2 Export
|
|
||||||
- `Ctrl+E` in TUI or `ExportCsv` command: exports active view to CSV.
|
|
||||||
|
|
||||||
### 7.3 Autosave
|
|
||||||
- Periodic autosave (every 30 seconds when dirty) to `.model.improv.autosave`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Technology
|
|
||||||
|
|
||||||
| Concern | Choice |
|
|
||||||
|---------|--------|
|
|
||||||
| Language | Rust (stable) |
|
|
||||||
| TUI | [Ratatui](https://github.com/ratatui-org/ratatui) + Crossterm |
|
|
||||||
| Serialization | `serde` + `serde_json` |
|
|
||||||
| Static binary | `x86_64-unknown-linux-musl` via `musl-gcc` |
|
|
||||||
| Dev environment | Nix flake with `rust-overlay` |
|
|
||||||
| No runtime deps | Single binary, no database, no network |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Non-Goals (v1)
|
|
||||||
|
|
||||||
- Scripting/macro language beyond the formula system.
|
|
||||||
- Collaborative/multi-user editing.
|
|
||||||
- Live external data sources (databases, APIs).
|
|
||||||
- Charts or graphical visualization.
|
|
||||||
- Multi-level undo history.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
nix develop --command cargo build --release
|
|
||||||
file target/x86_64-unknown-linux-musl/release/improvise # → statically linked
|
|
||||||
|
|
||||||
# Import test
|
|
||||||
./improvise --cmd '{"op":"ImportJson","path":"sample.json"}' --cmd '{"op":"Save","path":"test.improv"}'
|
|
||||||
|
|
||||||
# Formula test
|
|
||||||
./improvise test.improv \
|
|
||||||
--cmd '{"op":"AddFormula","raw":"Profit = Revenue - Cost","target_category":"Measure"}'
|
|
||||||
|
|
||||||
# Headless script
|
|
||||||
./improvise new.improv --script tests/setup.jsonl
|
|
||||||
|
|
||||||
# TUI
|
|
||||||
./improvise model.improv
|
|
||||||
```
|
|
||||||
299
context/design-principles.md
Normal file
299
context/design-principles.md
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
# Improvise Design Principles
|
||||||
|
|
||||||
|
## 1. Functional-First Architecture
|
||||||
|
|
||||||
|
### Commands Are Pure, Effects Are Side-Effectful
|
||||||
|
|
||||||
|
Every user action flows through a two-phase pipeline:
|
||||||
|
|
||||||
|
1. **Command** (`Cmd` trait) — reads immutable context, returns a list of effects.
|
||||||
|
The `CmdContext` is a read-only snapshot: model, layout, mode, cursor position.
|
||||||
|
Commands never touch `&mut App`. All decision logic is pure.
|
||||||
|
|
||||||
|
2. **Effect** (`Effect` trait) — a small struct with an `apply(&self, app: &mut App)` method.
|
||||||
|
Each effect is one discrete, debuggable state change. The app applies them in order.
|
||||||
|
|
||||||
|
This separation means:
|
||||||
|
- Commands are testable without a terminal or an `App` instance.
|
||||||
|
- Effects can be logged, replayed, or composed.
|
||||||
|
- The only place `App` is mutated is inside `Effect::apply`.
|
||||||
|
|
||||||
|
### Prefer Transformations to Mutation
|
||||||
|
|
||||||
|
Where possible, build new values rather than mutating in place:
|
||||||
|
- `CellKey::with(cat, item)` returns a new key with an added/replaced coordinate.
|
||||||
|
- `CellKey::without(cat)` returns a new key with a coordinate removed.
|
||||||
|
- Viewport positioning is computed as a pure function (`viewport_effects`) that
|
||||||
|
returns a `Vec<Effect>`, not a method that pokes at scroll offsets directly.
|
||||||
|
|
||||||
|
### Compose Small Pieces
|
||||||
|
|
||||||
|
Commands compose via `Binding::Sequence` — a keymap entry can chain multiple
|
||||||
|
commands, each contributing effects independently. The `o` key (add row + begin
|
||||||
|
editing) is two commands composed at the binding level, not a monolithic handler.
|
||||||
|
|
||||||
|
### Decompose rather than early return
|
||||||
|
|
||||||
|
Early Returns usually are a signal of mixed responsibilities: if an early return
|
||||||
|
would clarify a function, consider how the function could be decomposed for the
|
||||||
|
same effect without the early return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Polymorphism Over Conditionals
|
||||||
|
|
||||||
|
### Dispatch Through Traits and Registries, Not Match Blocks
|
||||||
|
|
||||||
|
- **Commands**: 40+ types each implement `Cmd`, organized by concern across
|
||||||
|
submodules in `command/cmd/` (navigation, cell, commit, grid, mode, panel,
|
||||||
|
search, text_buffer, tile, effect_cmds). A `CmdRegistry` maps names to
|
||||||
|
constructor closures. Dispatching a key press looks up the binding, resolves
|
||||||
|
the command name through the registry, and calls `execute`. No central
|
||||||
|
`match command_name { ... }` block.
|
||||||
|
|
||||||
|
- **Effects**: 50+ types each implement `Effect`. Collected into a `Vec<Box<dyn Effect>>`
|
||||||
|
and applied in order. No `match effect_kind { ... }`.
|
||||||
|
|
||||||
|
- **Keymaps**: Each mode has its own `Keymap` (a `HashMap<KeyPattern, Binding>`).
|
||||||
|
Mode dispatch is one table lookup, not a nested `match (mode, key)`.
|
||||||
|
|
||||||
|
### Use Enums to Make Invalid States Unrepresentable
|
||||||
|
|
||||||
|
- `BinOp` is an enum (`Add | Sub | Mul | ...`), not a string. Invalid operators
|
||||||
|
are caught at parse time, not silently ignored at eval time.
|
||||||
|
|
||||||
|
- `Axis` is `Row | Column | Page | None`. A category is on exactly one axis.
|
||||||
|
Cycling is a four-state rotation — no boolean flags, no "row_or_column" ambiguity.
|
||||||
|
|
||||||
|
- `Binding` is `Cmd | Prefix | Sequence`. The keymap lookup returns one of these
|
||||||
|
three shapes; dispatch pattern-matches exhaustively.
|
||||||
|
|
||||||
|
- `CategoryKind` is `Regular | VirtualIndex | VirtualDim | VirtualMeasure | Label`.
|
||||||
|
Business rules (e.g., the 12-category limit counts only `Regular`) are
|
||||||
|
enforced by matching on the enum, not by checking name prefixes. Virtual
|
||||||
|
categories (`_Index`, `_Dim`, `_Measure`) always exist: `_Index` and `_Dim`
|
||||||
|
support drill-down/records mode; `_Measure` holds numeric data fields and
|
||||||
|
formula targets (added automatically by `add_formula`). Use
|
||||||
|
`Model::regular_category_names()` when selecting a default category for
|
||||||
|
prompts or other user-visible choices.
|
||||||
|
|
||||||
|
### When You Add a Variant, the Compiler Finds Every Call Site
|
||||||
|
|
||||||
|
Prefer exhaustive `match` over `if let` or `_ =>` wildcards. When a new `Axis`
|
||||||
|
variant or `AppMode` is added, non-exhaustive matches produce compile errors
|
||||||
|
that guide you to every place that needs updating.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Correctness by Construction
|
||||||
|
|
||||||
|
### Canonical Forms Prevent Equivalence Bugs
|
||||||
|
|
||||||
|
`CellKey::new()` sorts coordinates by category name. Two keys that name the same
|
||||||
|
intersection but in different order are identical after construction. Equality,
|
||||||
|
hashing, and storage all work correctly without callers needing to remember to
|
||||||
|
sort. Property tests verify this invariant.
|
||||||
|
|
||||||
|
### Smart Constructors Enforce Invariants
|
||||||
|
|
||||||
|
- `CellKey::new()` is the only way to build a key — it always sorts.
|
||||||
|
- `Category::add_item()` deduplicates by name and auto-assigns IDs via a private
|
||||||
|
counter. External code cannot fabricate an `ItemId`.
|
||||||
|
- `Model::add_category()` checks the 12-category limit before insertion.
|
||||||
|
- `Formula::new()` takes all required fields; there is no default/empty formula
|
||||||
|
to accidentally leave half-initialized.
|
||||||
|
|
||||||
|
### Type-Safe Identifiers
|
||||||
|
|
||||||
|
`CategoryId` and `ItemId` are typed aliases. While they are `usize` underneath,
|
||||||
|
using named types signals intent and prevents accidentally passing an item count
|
||||||
|
where an item ID is expected.
|
||||||
|
|
||||||
|
### Symbol Interning for Data Integrity
|
||||||
|
|
||||||
|
`DataStore` interns category and item names into `Symbol` values (small copyable
|
||||||
|
handles). This means:
|
||||||
|
- String comparison is integer comparison — fast and allocation-free.
|
||||||
|
- A secondary index maps `(Symbol, Symbol)` pairs to cell sets, enabling O(1)
|
||||||
|
lookups for aggregation queries.
|
||||||
|
- Symbols can only be created through the `SymbolTable`, so misspelled names
|
||||||
|
produce a distinct symbol rather than silently matching a wrong cell.
|
||||||
|
|
||||||
|
### Parse-Time Validation
|
||||||
|
|
||||||
|
Formulas are parsed into a typed AST (`Expr` enum) at entry time. If the syntax
|
||||||
|
is invalid, the user gets an error immediately. The evaluator only sees
|
||||||
|
well-formed trees — it does not need to handle malformed input.
|
||||||
|
|
||||||
|
### Grammar-Defined File Format
|
||||||
|
|
||||||
|
The `.improv` file format is defined by a PEG grammar (`persistence/improv.pest`)
|
||||||
|
and parsed by pest. The grammar is the single source of truth — the parser is a
|
||||||
|
tree-walker over the grammar's parse tree, not an ad-hoc line scanner. This means:
|
||||||
|
|
||||||
|
- Adding a new format feature means updating the grammar first, then the walker.
|
||||||
|
- The grammar can be read as a specification independent of the Rust code.
|
||||||
|
- A grammar-walking test generator reads the grammar AST at test time (via
|
||||||
|
`pest_meta`) and produces random valid files, ensuring the parser accepts
|
||||||
|
everything the grammar describes.
|
||||||
|
|
||||||
|
### CL-Style Pipe Quoting for Names
|
||||||
|
|
||||||
|
Names in the `.improv` format use CL-style `|...|` pipe quoting. A name is bare
|
||||||
|
if it matches `[A-Za-z_][A-Za-z0-9_-]*`; everything else must be pipe-quoted.
|
||||||
|
Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline).
|
||||||
|
This convention is shared between the `.improv` persistence format and the
|
||||||
|
formula parser's identifier syntax.
|
||||||
|
|
||||||
|
### Formula Tokenizer: Identifiers and Quoting
|
||||||
|
|
||||||
|
**Bare identifiers** support multi-word names (e.g., `Total Revenue`) by
|
||||||
|
allowing spaces when followed by non-operator, non-keyword characters. Keywords
|
||||||
|
(`WHERE`, `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`, `IF`) act as token boundaries.
|
||||||
|
|
||||||
|
**Pipe-quoted identifiers** (`|...|`) allow any characters — including spaces,
|
||||||
|
keywords, and operators — inside the delimiters. Use pipes when a category or
|
||||||
|
item name collides with a keyword or contains special characters:
|
||||||
|
|
||||||
|
```
|
||||||
|
|WHERE| — category named "WHERE"
|
||||||
|
|Revenue (USD)| — name with parens
|
||||||
|
|Cost + Tax| — name with operator chars
|
||||||
|
SUM(|Net Revenue| WHERE |Region Name| = |East Coast|)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pipes produce `Token::Ident` (same as bare identifiers), so they work
|
||||||
|
everywhere an identifier is expected: expressions, aggregate arguments, WHERE
|
||||||
|
clause category names and filter values. Double-quoted strings (`"..."`)
|
||||||
|
remain `Token::Str` and are used only for WHERE filter values in the
|
||||||
|
`split_where` pre-parse step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Separation of Concerns
|
||||||
|
|
||||||
|
### Four Layers
|
||||||
|
|
||||||
|
| Layer | Directory | Responsibility |
|
||||||
|
|-------|-----------|----------------|
|
||||||
|
| **Model** | `src/model/` | Categories, items, groups, cell data, formulas. Pure data, no rendering. |
|
||||||
|
| **View** | `src/view/` | Axis assignments, page selection, hidden items, layout computation. Derived from model. |
|
||||||
|
| **Command / Effect** | `src/command/`, `src/ui/effect.rs` | Intent (commands) and state mutation (effects). Bridges user input to model changes. |
|
||||||
|
| **Rendering** | `src/draw.rs`, `src/ui/` | Terminal drawing. Reads model + view, writes pixels. No mutation. |
|
||||||
|
|
||||||
|
### Formulas Are Data, Not Code
|
||||||
|
|
||||||
|
A formula is a serializable struct: raw text, target name, category, AST, optional
|
||||||
|
filter. It is stored in the model alongside regular data. The evaluator walks the
|
||||||
|
AST at read time. Formulas never become closures or runtime-generated code.
|
||||||
|
|
||||||
|
### Formula Evaluation Is Fixed-Point
|
||||||
|
|
||||||
|
`recompute_formulas(none_cats)` iterates formula evaluation until values
|
||||||
|
stabilize. Each pass evaluates all formula cells using the current cache
|
||||||
|
(for formula-derived values) and raw data aggregation (for data values).
|
||||||
|
This avoids recursive evaluation through `evaluate_aggregated` and
|
||||||
|
naturally handles chained formulas (`Margin = Profit / Revenue` where
|
||||||
|
`Profit = Revenue - Cost`). Circular references converge to
|
||||||
|
`CellValue::Error("circular")` after `MAX_EVAL_DEPTH` iterations.
|
||||||
|
|
||||||
|
### Display Rounding Is View-Only
|
||||||
|
|
||||||
|
Number formatting (`format_f64`) rounds for display. Formula evaluation always
|
||||||
|
operates on full `f64` precision. The rounding function is only called in
|
||||||
|
rendering paths — never in `eval_formula` or aggregation.
|
||||||
|
|
||||||
|
### Drill State Isolates Edits
|
||||||
|
|
||||||
|
When editing aggregated (drill-down) cells, a `DrillState` snapshot freezes the
|
||||||
|
current cell set. Pending edits accumulate in a staging map. On commit,
|
||||||
|
`ApplyAndClearDrill` writes them all atomically. On cancel, the snapshot is
|
||||||
|
discarded. No partial writes reach the model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Guidelines for New Code
|
||||||
|
|
||||||
|
- **Add a command, not a special case.** If you need new behavior on a keypress,
|
||||||
|
implement `Cmd`, register it, and bind it in the keymap. Do not add an
|
||||||
|
`if key == 'x'` branch inside `handle_key`.
|
||||||
|
|
||||||
|
- **Return effects, do not mutate.** Your command's `execute` receives `&CmdContext`
|
||||||
|
(immutable). Produce a `Vec<Box<dyn Effect>>`. If you need a new kind of state
|
||||||
|
change, create a new `Effect` struct.
|
||||||
|
|
||||||
|
- **Use the type system.** If a value can only be one of N things, make it an enum.
|
||||||
|
If an invariant must hold, enforce it in the constructor. If a field is
|
||||||
|
optional, use `Option` — do not use sentinel values.
|
||||||
|
|
||||||
|
- **Test the logic, not the wiring.** Commands are pure functions of context;
|
||||||
|
test them by building a `CmdContext` and asserting on the returned effects.
|
||||||
|
You do not need a terminal.
|
||||||
|
|
||||||
|
- **Keep `Option`/`Result`/`Box` at the boundaries.** Core logic should work with
|
||||||
|
concrete values. Wrap in `Option` at the edges (parsing, lookup, I/O) and
|
||||||
|
unwrap early. Do not thread `Option` through deep call chains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
### Coverage and ambition
|
||||||
|
|
||||||
|
Aim for **~80% line and branch coverage** on logic code. This is a quality floor —
|
||||||
|
go higher where the code is tricky or load-bearing, but don't pad coverage by
|
||||||
|
testing trivial getters or chasing 100% on rendering widgets. The test suite
|
||||||
|
should remain fast (under 2 seconds). Slow tests erode the habit of running them.
|
||||||
|
|
||||||
|
### Demonstrate bugs before fixing them
|
||||||
|
|
||||||
|
Write a test that **fails on the current code** before writing the fix. Prefer a
|
||||||
|
small unit test targeting the broken function over an end-to-end test. After the
|
||||||
|
fix, the test stays as a regression guard. Document the bug in the test's
|
||||||
|
doc-comment (see `model/types.rs` → `formula_tests` for examples).
|
||||||
|
|
||||||
|
### Use property tests judiciously
|
||||||
|
|
||||||
|
Property tests (`proptest`) are for **invariants that must hold across all
|
||||||
|
inputs** — not a replacement for example-based tests. Good candidates:
|
||||||
|
|
||||||
|
- Structural invariants: CellKey is always sorted, each category lives on exactly
|
||||||
|
one axis, toggle-collapse is involutive, hide/show roundtrips.
|
||||||
|
- Serialization roundtrips: save/load identity.
|
||||||
|
- Determinism: `evaluate` returns the same result for the same inputs.
|
||||||
|
|
||||||
|
Keep case counts at the default (256). Don't crank them to thousands — if a
|
||||||
|
property needs more cases to feel safe, constrain the input space with better
|
||||||
|
strategies rather than brute-forcing. Property tests that take hundreds of
|
||||||
|
milliseconds each are a sign something is wrong.
|
||||||
|
|
||||||
|
### What to test
|
||||||
|
|
||||||
|
- **Model, formula, view**: the core logic. Unit tests for each operation and
|
||||||
|
edge case. Property tests for invariants. These are the highest-value tests.
|
||||||
|
- **Commands**: build a `CmdContext`, call `execute`, assert on the returned
|
||||||
|
effects. Pure functions — no terminal needed. Tests are colocated in each
|
||||||
|
command submodule (`command/cmd/<module>.rs` → `mod tests`), with shared
|
||||||
|
test helpers in `command/cmd/mod.rs::test_helpers`.
|
||||||
|
- **Persistence**: round-trip tests (`save → load → save` produces identical
|
||||||
|
output) plus grammar-driven property tests. The generator walks the pest
|
||||||
|
grammar AST to produce random valid files; proptests verify
|
||||||
|
`parse(generate())` succeeds and `parse(format(parse(generate())))` is
|
||||||
|
stable. Cover groups, formulas, views, hidden items, pipe quoting edges.
|
||||||
|
- **Format**: boundary cases for comma placement, rounding, negative numbers.
|
||||||
|
- **Import**: field classification heuristics, CSV quoting, multi-file merge.
|
||||||
|
|
||||||
|
### What not to test
|
||||||
|
|
||||||
|
- Ratatui `Widget::render` implementations — pure drawing code that changes
|
||||||
|
often. Test the data they consume (layout, cat_tree, format) instead.
|
||||||
|
- Trivial data definitions (`ast.rs`, `axis.rs`).
|
||||||
|
- Module re-export files.
|
||||||
|
|
||||||
|
### Test the property, not the implementation
|
||||||
|
|
||||||
|
A test like "calling `set_axis(cat, Row)` sets the internal map entry to `Row`"
|
||||||
|
is brittle — it mirrors the implementation and breaks if the storage changes.
|
||||||
|
Instead test the observable contract: "after `set_axis(cat, Row)`,
|
||||||
|
`axis_of(cat)` returns `Row` and `categories_on(Row)` includes `cat`." This
|
||||||
|
style survives refactoring and catches real bugs.
|
||||||
284
context/plan.md
Normal file
284
context/plan.md
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
# improvise — launch preparation plan
|
||||||
|
|
||||||
|
You are working in the `improvise` repository, a Rust terminal application that implements a Lotus Improv-style pivot-table modeling tool. Cells are keyed by sets of `(category, item)` coordinates rather than 2D grid addresses; views assign categories to row/column/page axes; formulas reference dimension names rather than cell addresses. The application is built with `ratatui` + `crossterm`, persists models in a markdown-based `.improv` format (with optional gzip), and supports both an interactive TUI and a headless command/script mode through a unified registry.
|
||||||
|
|
||||||
|
Your job is to prepare this repository for a public Show HN launch. The bar is "ready for strangers to install and try in 60 seconds," not "feature-complete." Do tasks in the order given. Do not start a later phase before earlier phases are complete. Do not add features, refactor warts, or restructure modules — those are explicitly out of scope and listed at the end.
|
||||||
|
|
||||||
|
The target audience is developers who use vim, who have opinions about data modeling, and who would understand a Lotus Improv reference. The pitch positions improvise as a pivot-table modeling tool, not "a spreadsheet in a terminal" — that framing competes with sc-im and VisiData and loses.
|
||||||
|
|
||||||
|
The development environment is Nix-based (the repo has a `flake.nix`). The user does not have Homebrew installed and will not install it. All tooling must be available through Nix, and any process that needs new tools must be codified as additions to `flake.nix` so the user can reach them via `nix develop` or `nix run`. Do not suggest `brew install` anywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Repository hygiene
|
||||||
|
|
||||||
|
Goal: raise the quality floor of what a stranger sees when they land on the repo. Roughly 90 minutes of work.
|
||||||
|
|
||||||
|
### 1.1 Audit and remove or update `context/SPEC.md`
|
||||||
|
|
||||||
|
Read `context/SPEC.md` and compare it against the actual code. The spec is likely stale: it may describe a JSON import wizard when the real wizard handles CSV, and it may describe JSON persistence when the real format is markdown (look at `src/persistence/mod.rs` to confirm). It may also list commands that no longer exist in the registry.
|
||||||
|
|
||||||
|
If the spec contradicts the code in significant ways, delete it. If you want to preserve design intent, move salvageable conceptual content to `docs/design-notes.md` with a header that reads:
|
||||||
|
|
||||||
|
> **Note:** This document captures design intent and may not reflect the current implementation. The README and source code are authoritative.
|
||||||
|
|
||||||
|
Do not try to bring the spec back into sync with the code — that is not worth the effort for a personal project, and a stale spec is worse than no spec.
|
||||||
|
|
||||||
|
### 1.2 Fix `Cargo.toml` metadata
|
||||||
|
|
||||||
|
The current `[package]` section is missing fields that crates.io and `cargo dist` need. Update it to include:
|
||||||
|
|
||||||
|
- `description` — replace any generic placeholder with: `"Terminal pivot-table modeling in the spirit of Lotus Improv"`
|
||||||
|
- `repository` — ask the user for the GitHub URL if you don't already know it; otherwise leave a `TODO` comment and flag it
|
||||||
|
- `homepage` — same URL as `repository`
|
||||||
|
- `documentation` — same URL as `repository` for now
|
||||||
|
- `readme = "README.md"`
|
||||||
|
- `keywords = ["tui", "pivot", "spreadsheet", "data", "improv"]` (max 5 keywords, each ≤20 chars)
|
||||||
|
- `categories = ["command-line-utilities", "visualization"]`
|
||||||
|
- `license` — confirm this is set; if not, ask the user
|
||||||
|
|
||||||
|
Preserve all existing fields and dependency entries.
|
||||||
|
|
||||||
|
### 1.3 Verify publish-readiness
|
||||||
|
|
||||||
|
Run `cargo publish --dry-run` from inside `nix develop`. Fix any errors or warnings. Common issues: missing license file, files too large to publish, dependencies with incompatible versions. Do not actually publish — that happens in Phase 3.
|
||||||
|
|
||||||
|
### 1.4 Audit CSV quote handling
|
||||||
|
|
||||||
|
Look at `src/import/csv_parser.rs` (or wherever CSV parsing lives). Verify that it correctly handles RFC 4180 quoted fields: fields enclosed in double quotes, embedded commas inside quoted fields, and escaped quotes (`""` inside a quoted field).
|
||||||
|
|
||||||
|
If the parser is using the `csv` crate from `Cargo.toml`, this should be handled correctly by default — verify that the code is actually using it and not doing manual `split(',')` anywhere. If there are any places that do manual splitting or fragile quote handling, fix them by routing through the `csv` crate. Add a unit test that round-trips a CSV row with `"O'Reilly, Inc."` (embedded comma) and `"She said ""hi"""` (embedded escaped quotes).
|
||||||
|
|
||||||
|
If the parser is fundamentally broken in ways that can't be fixed quickly, do not block on this. Add a one-line note to the README under a "Known limitations" section: "CSV files with unusual quoting may not parse correctly; PRs welcome."
|
||||||
|
|
||||||
|
### 1.5 Create `examples/demo.improv` and `examples/demo.csv`
|
||||||
|
|
||||||
|
Create two synthetic example files with obviously-fake data. These exist so a new user can run `improvise examples/demo.improv` and immediately see a working pivot, or `improvise --import examples/demo.csv` and walk through the wizard.
|
||||||
|
|
||||||
|
**`examples/demo.csv`** should contain ~30-50 rows with columns like:
|
||||||
|
- `Date` (mix of dates across at least 2 quarters of one year)
|
||||||
|
- `Region` (3-4 values: North, South, East, West)
|
||||||
|
- `Product` (3-4 values: Widget, Gadget, Sprocket, Doohickey)
|
||||||
|
- `Customer` (5-8 fake company names: Acme Corp, Globex, Initech, Umbrella, Soylent, etc.)
|
||||||
|
- `Revenue` (round numbers, 100-10000)
|
||||||
|
- `Cost` (round numbers, less than Revenue)
|
||||||
|
|
||||||
|
**`examples/demo.improv`** should be the result of importing `demo.csv` through the tool, then manually saving. To create it: build the tool, run the import on `demo.csv`, optionally pivot it into an interesting default view (e.g., Region on rows, Date_Quarter on columns, Product on page axis), add a sample formula like `Profit = Revenue - Cost` if the formula system supports it, save as `examples/demo.improv`, and commit both files.
|
||||||
|
|
||||||
|
If you cannot determine the exact format syntax, look at existing test fixtures or run the tool's save path on a small in-memory model to generate one.
|
||||||
|
|
||||||
|
The data must be obviously synthetic. Do not copy data from any other file in the repo. Do not use any real-looking names, real amounts, or anything that looks like it might be from a real bank export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — README and demo artifacts
|
||||||
|
|
||||||
|
This is the main weekend's work. The README is 80% of the launch. The demo artifacts are the other 20%. Nothing in Phase 3 or 4 matters if Phase 2 is weak.
|
||||||
|
|
||||||
|
### 2.1 Add Nix tooling for asciinema and VHS
|
||||||
|
|
||||||
|
The user does not have Homebrew. Both asciinema (terminal session recording) and VHS (Charmbracelet's terminal-to-GIF tool, package name `vhs` in nixpkgs) need to be available through Nix.
|
||||||
|
|
||||||
|
Modify `flake.nix` to add `pkgs.asciinema` and `pkgs.vhs` to the dev shell's `nativeBuildInputs`. Both packages exist in nixpkgs unstable; verify before adding. If `vhs` requires additional runtime dependencies (it uses `ttyd` and `ffmpeg` internally), add those too — check the nixpkgs `vhs` derivation to see what's already bundled.
|
||||||
|
|
||||||
|
After modifying the flake, verify that `nix develop --command asciinema --version` and `nix develop --command vhs --version` both work.
|
||||||
|
|
||||||
|
While you're in the flake, also add:
|
||||||
|
- `pkgs.cargo-dist` for the release tooling in Phase 3 (if it's packaged in nixpkgs; if not, fall back to running it via `cargo install` inside the dev shell and note this in a comment)
|
||||||
|
- A `nix run` app or shell alias for the demo recording workflow (see 2.4)
|
||||||
|
|
||||||
|
### 2.2 Write the README
|
||||||
|
|
||||||
|
Replace any existing `README.md` with a new one structured as follows. Aim for under 250 lines total.
|
||||||
|
|
||||||
|
**Section order (do not deviate):**
|
||||||
|
|
||||||
|
1. **Title** — `# improvise`
|
||||||
|
|
||||||
|
2. **One-sentence pitch** — italicized, immediately under the title:
|
||||||
|
> *Terminal pivot-table modeling in the spirit of Lotus Improv — multidimensional cells, formulas over dimensions instead of cell addresses, and vim-style keybindings for reassigning axes on the fly.*
|
||||||
|
|
||||||
|
3. **Inline animated demo** — `` (the GIF generated in step 2.5). This must be near the top so it shows in HN previews and the GitHub repo card.
|
||||||
|
|
||||||
|
4. **Why this exists** — exactly one paragraph. Explain the Improv data/view separation and why no terminal tool currently does it. Reference Lotus Improv (NeXT, 1991) by name. Briefly note that Excel pivot tables took the visual idea but not the formula model. Do not bash other tools — name sc-im and VisiData neutrally as adjacent-but-different.
|
||||||
|
|
||||||
|
5. **Quick start** — three code blocks:
|
||||||
|
```
|
||||||
|
nix develop
|
||||||
|
cargo build --release
|
||||||
|
./target/release/improvise examples/demo.improv
|
||||||
|
```
|
||||||
|
Followed by a one-line "or import your own CSV":
|
||||||
|
```
|
||||||
|
./target/release/improvise --import path/to/data.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Key bindings to try first** — short list, not a complete reference. `T` for tile mode (reassign axes), `[` `]` for page axis cycling, `>` to drill into a cell, `<` to return, `F` for formula panel, `:w` to save, `:q` to quit, `?` or `F1` for full help.
|
||||||
|
|
||||||
|
7. **Installation** — three subsections:
|
||||||
|
- **From source with Nix** (preferred): `nix develop` then `cargo build --release`, then optionally `cargo install --path .`
|
||||||
|
- **From crates.io**: `cargo install improvise` (will be valid after Phase 3)
|
||||||
|
- **Prebuilt binaries**: link to GitHub releases page (will be populated after Phase 3)
|
||||||
|
|
||||||
|
Do not mention Homebrew. Do not mention `apt`, `dnf`, or other distro package managers.
|
||||||
|
|
||||||
|
8. **What's interesting about the codebase** — 10-15 lines, prose, no bullets. Cover: the multidimensional data model with categories instead of grid coordinates; the view layer as a pure function from `(Model, View)` to `GridLayout`; records mode as just another axis assignment; the command/effect architecture that lets the same registry serve both interactive dispatch and headless scripts; the markdown `.improv` persistence format that's human-readable and git-diffable.
|
||||||
|
|
||||||
|
9. **Expectations** — mandatory disclaimer paragraph, exact wording:
|
||||||
|
> improvise is a personal project I built for my own use. I'm sharing it because other people might find it useful, but I can't promise active maintenance or feature development. Issues and PRs are welcome but may not get a fast response. If you want to build on it, fork away.
|
||||||
|
|
||||||
|
10. **License** — one line.
|
||||||
|
|
||||||
|
Do not add: a table of contents, badges (build status, version, license badges, etc.), a contributing guide, a code of conduct, an "inspired by" gratitude section. These are noise for a personal project launch.
|
||||||
|
|
||||||
|
### 2.3 Create `docs/demo.tape` and generate `docs/demo.gif`
|
||||||
|
|
||||||
|
VHS is scripted: you write a `.tape` file describing keystrokes and timing, and VHS produces a GIF. This GIF goes inline in the README and is the single highest-leverage artifact in the launch — it's what shows up in HN preview cards and Google search results.
|
||||||
|
|
||||||
|
Create `docs/demo.tape` that scripts a ~20-second flow showing the pivot reassignment killer demo. Rough script structure (consult VHS docs for exact syntax):
|
||||||
|
|
||||||
|
```
|
||||||
|
Output docs/demo.gif
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1000
|
||||||
|
Set Height 600
|
||||||
|
Set Theme "Dracula"
|
||||||
|
|
||||||
|
Type "improvise examples/demo.improv"
|
||||||
|
Enter
|
||||||
|
Sleep 1500ms
|
||||||
|
|
||||||
|
# Show initial pivot
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Enter tile mode and reassign an axis
|
||||||
|
Type "T"
|
||||||
|
Sleep 800ms
|
||||||
|
# (whatever keystrokes reassign Region from rows to columns)
|
||||||
|
Sleep 1500ms
|
||||||
|
|
||||||
|
# Show the new pivot
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Reassign again to demonstrate the speed
|
||||||
|
# (more keystrokes)
|
||||||
|
Sleep 1500ms
|
||||||
|
|
||||||
|
# Exit
|
||||||
|
Type ":q"
|
||||||
|
Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate the GIF by running `nix develop --command vhs docs/demo.tape`. Iterate on the script until the GIF is under 5MB, the timing is readable (not too fast, not too slow), and the key beats are clear: start in pivot view → press T → axis reassigns → press T again → axis reassigns again. The viewer should be able to understand "this tool re-pivots data with one keystroke" without any narration.
|
||||||
|
|
||||||
|
### 2.4 Record asciinema casts
|
||||||
|
|
||||||
|
Asciinema produces `.cast` files that the asciinema-player JS widget can replay in a browser, with selectable text and pixel-perfect terminal rendering. These will be embedded in the GitHub Pages landing page in Phase 4.
|
||||||
|
|
||||||
|
Before recording, set the terminal to exactly 100 columns by 30 rows: `stty cols 100 rows 30` (or resize the terminal window manually if `stty` doesn't take). This matters because the asciinema player renders at the recorded dimensions and a wrong size will look broken on the landing page.
|
||||||
|
|
||||||
|
Record four casts under `docs/casts/`:
|
||||||
|
|
||||||
|
- **`docs/casts/import.cast`** — start with `improvise` (no args, empty model), trigger CSV import for `examples/demo.csv`, walk through the wizard accepting defaults, end in pivot view.
|
||||||
|
- **`docs/casts/pivot.cast`** — start from `improvise examples/demo.improv`, demonstrate axis reassignment with `T`. This is the same flow as the README GIF but longer and more complete. Show 2-3 different pivots.
|
||||||
|
- **`docs/casts/drill.cast`** — from a pivot view, press `>` to drill into an aggregated cell, show the records view, demonstrate that you can edit a record, press `<` to return.
|
||||||
|
- **`docs/casts/formulas.cast`** — from a pivot view, open the formula panel with `F`, add `Profit = Revenue - Cost`, show it appearing across the pivot.
|
||||||
|
|
||||||
|
Record each with `nix develop --command asciinema rec -i 2 docs/casts/<name>.cast`. The `-i 2` flag caps idle gaps at 2 seconds, which prevents long pauses from making the playback feel dead. Each cast should be under 60 seconds.
|
||||||
|
|
||||||
|
If a take has flubs, delete it and re-record. Do not try to edit the JSON cast files manually.
|
||||||
|
|
||||||
|
Add a `nix run` app to the flake or a shell script in `scripts/record-demo.sh` that wraps the recording workflow with the right `stty` setup, so the user can re-record consistently in the future without remembering the exact incantation.
|
||||||
|
|
||||||
|
### 2.5 Verify all artifacts exist and are committed
|
||||||
|
|
||||||
|
Before moving to Phase 3, confirm:
|
||||||
|
- `README.md` exists with all 10 sections
|
||||||
|
- `docs/demo.gif` exists, is referenced from the README, and is under 5MB
|
||||||
|
- `docs/demo.tape` exists and regenerates the GIF when run through VHS
|
||||||
|
- `docs/casts/import.cast`, `pivot.cast`, `drill.cast`, `formulas.cast` all exist
|
||||||
|
- `examples/demo.csv` and `examples/demo.improv` exist and contain only synthetic data
|
||||||
|
- `flake.nix` includes `asciinema`, `vhs`, and `cargo-dist` (or a fallback for `cargo-dist`)
|
||||||
|
- `nix develop` succeeds and all the tools above are on PATH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Distribution
|
||||||
|
|
||||||
|
### 3.1 Configure `cargo dist`
|
||||||
|
|
||||||
|
`cargo dist` generates a GitHub Actions workflow that produces release tarballs and installer scripts when you push a version tag. Run `nix develop --command cargo dist init` and configure for these targets:
|
||||||
|
|
||||||
|
- `x86_64-unknown-linux-gnu`
|
||||||
|
- `aarch64-apple-darwin`
|
||||||
|
- `x86_64-apple-darwin`
|
||||||
|
|
||||||
|
Skip Windows. Skip musl unless `cargo dist init` strongly recommends it — the existing flake doesn't build for musl and adding that complication is out of scope.
|
||||||
|
|
||||||
|
Commit the generated `.github/workflows/release.yml` and the additions to `Cargo.toml`. Test the workflow by tagging `v0.1.0-rc1` and pushing the tag to a branch — verify the release builds successfully on GitHub Actions before doing a real release. Delete the rc tag afterward.
|
||||||
|
|
||||||
|
### 3.2 Publish to crates.io
|
||||||
|
|
||||||
|
After `cargo publish --dry-run` is clean (from step 1.3) and the user confirms they're ready, run `nix develop --command cargo publish`. Verify the crate appears at `https://crates.io/crates/improvise` and that `cargo install improvise` works on a clean machine (or in a fresh `nix shell --packages cargo`).
|
||||||
|
|
||||||
|
### 3.3 Tag the v0.1.0 release
|
||||||
|
|
||||||
|
Create a git tag `v0.1.0`, push it, and verify the `cargo dist` workflow produces release artifacts. Update the README's "Prebuilt binaries" link to point at the actual release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Landing page (optional but recommended)
|
||||||
|
|
||||||
|
### 4.1 Create `docs/index.html`
|
||||||
|
|
||||||
|
Vanilla HTML, single file, under 200 lines. No framework, no build step. Dark background, monospace headings to match the terminal aesthetic. Embed the asciinema-player from jsdelivr CDN (`https://cdn.jsdelivr.net/npm/asciinema-player@3/dist/bundle/`) and reference the four `.cast` files from `docs/casts/`.
|
||||||
|
|
||||||
|
Section structure: title and one-line tagline → "Reassign axes on the fly" with the pivot cast → "Drill into aggregated cells" with the drill cast → "Formulas over dimensions" with the formulas cast → "Import a CSV" with the import cast → "Get it" with the install commands and a GitHub link.
|
||||||
|
|
||||||
|
Each cast should be embedded with `AsciinemaPlayer.create()` configured with `rows: 30, cols: 100, theme: 'monokai', autoPlay: false, loop: true`.
|
||||||
|
|
||||||
|
### 4.2 Enable GitHub Pages
|
||||||
|
|
||||||
|
In the GitHub repo settings, enable Pages with source set to the `main` branch and folder set to `/docs`. Verify the site is live at `https://<user>.github.io/improvise/` within a few minutes of pushing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Buffer
|
||||||
|
|
||||||
|
The second weekend (or the second half of the first weekend) is reserved for whatever was missed in Phases 1-4. Do not use it to add features. Do not use it to fix the issues listed in "Out of scope" below. If everything is done early, stop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope — do not do these before launch
|
||||||
|
|
||||||
|
The following are real issues that have been identified but are explicitly not launch-blocking. Do not start any of them as part of this plan. They can be addressed after launch based on what real users actually trip over.
|
||||||
|
|
||||||
|
- **Do not** raise or restructure the `MAX_CATEGORIES = 12` limit, even though it's tight for some real use cases.
|
||||||
|
- **Do not** refactor the `ExecuteCommand` vim command match ladder, even though it duplicates registry logic.
|
||||||
|
- **Do not** fix the hardcoded `"Measure"` category name in `evaluate_aggregated`, even though it's the one place in the code that assumes a specific user-facing name.
|
||||||
|
- **Do not** change `Model::evaluate` from a linear formula walk to a HashMap lookup, even though it's O(N·M·F) in the render hot path.
|
||||||
|
- **Do not** add render-pass memoization to `matching_values` calls.
|
||||||
|
- **Do not** fix the `ApplyAndClearDrill` HashMap iteration ordering issue around coordinate rename + value edit interactions.
|
||||||
|
- **Do not** add new features: no YoY references, no parameterized formulas, no Datalog backend, no undo/redo, no charts, no plugin system.
|
||||||
|
- **Do not** write a documentation site beyond the single GitHub Pages landing page in Phase 4.
|
||||||
|
- **Do not** add Windows support.
|
||||||
|
- **Do not** rename types, restructure modules, or do any "while I'm in here" cleanups.
|
||||||
|
|
||||||
|
Each of these is worth doing eventually. None of them is worth doing before the launch post is up. The goal of this plan is to get the existing tool in front of strangers in a form they can install and try, not to make the tool perfect first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
The plan is complete when, on a clean machine with only Nix installed:
|
||||||
|
|
||||||
|
1. `git clone <repo> && cd improvise && nix develop` succeeds.
|
||||||
|
2. `cargo build --release` succeeds.
|
||||||
|
3. `./target/release/improvise examples/demo.improv` opens an interesting pivot immediately.
|
||||||
|
4. The README renders correctly on GitHub with the inline GIF playing.
|
||||||
|
5. `cargo install improvise` from a fresh shell works and produces a runnable binary.
|
||||||
|
6. The GitHub releases page has prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).
|
||||||
|
7. The GitHub Pages site at `https://<user>.github.io/improvise/` loads and the asciinema casts play.
|
||||||
|
8. Nothing in the "Out of scope" list has been touched.
|
||||||
|
|
||||||
|
When all eight conditions hold, stop. Report back to the user with a summary of what was done and any blockers encountered. Do not post to Hacker News yourself — that's the user's call and their timing.
|
||||||
254
context/repo-map.md
Normal file
254
context/repo-map.md
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
# Repository Map (LLM Reference)
|
||||||
|
|
||||||
|
Terminal pivot-table app. Rust 2024, Ratatui TUI, command/effect architecture.
|
||||||
|
Apache-2.0. Root binary+lib `improvise`; sub-crates under `crates/`:
|
||||||
|
|
||||||
|
- `improvise-core` — `Model`, `View`, `Workbook`, number formatting. No UI/IO.
|
||||||
|
- `improvise-formula` — formula parser, AST, `parse_formula`.
|
||||||
|
- `improvise-io` — `.improv` save/load, CSV/JSON import. No UI/commands.
|
||||||
|
|
||||||
|
The main crate re-exports each as `crate::{model, view, workbook, format, formula, persistence, import}` so consumer paths stay stable when crates shuffle.
|
||||||
|
|
||||||
|
Architectural intent lives in `context/design-principles.md` — read that for the "why". This doc is the "where".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Find Things
|
||||||
|
|
||||||
|
| I need to... | Look in |
|
||||||
|
|-------------------------------------|-------------------------------------------------|
|
||||||
|
| Add a keybinding | `command/keymap.rs` → `default_keymaps()` |
|
||||||
|
| Add a user command | `command/cmd/<submodule>.rs`, register in `registry.rs` |
|
||||||
|
| Add a state mutation | `ui/effect.rs` → implement `Effect` |
|
||||||
|
| Change formula eval | `model/types.rs` → `eval_formula` / `eval_expr` |
|
||||||
|
| Change cell storage / lookup | `model/cell.rs` → `DataStore` |
|
||||||
|
| Change category/item behavior | `model/category.rs` → `Category` |
|
||||||
|
| Change view axis logic | `view/types.rs` → `View` |
|
||||||
|
| Change grid layout | `view/layout.rs` → `GridLayout` |
|
||||||
|
| Change `.improv` format | `persistence/improv.pest` + `persistence/mod.rs` |
|
||||||
|
| Change number display | `format.rs` → `format_f64` |
|
||||||
|
| Change CLI args | `main.rs` (clap) |
|
||||||
|
| Change import logic | `import/wizard.rs` → `ImportPipeline` |
|
||||||
|
| Change frame layout | `draw.rs` → `draw()` |
|
||||||
|
| Change app state / modes | `ui/app.rs` → `App`, `AppMode` |
|
||||||
|
| Write a test for model logic | `model/types.rs` → `mod tests` / `formula_tests` |
|
||||||
|
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests`; helpers in `cmd/mod.rs::test_helpers` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Central Pattern: Cmd → Effect
|
||||||
|
|
||||||
|
```
|
||||||
|
keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
|
||||||
|
(pure, read-only ctx) (list of mutations) (only mutation site)
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait Cmd: Debug + Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Effect: Debug {
|
||||||
|
fn apply(&self, app: &mut App);
|
||||||
|
fn changes_mode(&self) -> bool { false } // override when Effect swaps AppMode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `GridLayout`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors.
|
||||||
|
|
||||||
|
**Add a command**: implement `Cmd` in a `command/cmd/` submodule, register in `registry.rs`, bind in `default_keymaps()`. Simple wrappers go through the `effect_cmd!` macro in `effect_cmds.rs`.
|
||||||
|
|
||||||
|
**Add an effect**: implement `Effect` in `ui/effect.rs`, add a constructor fn if it helps composition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Types (skim; read the source for fields/methods)
|
||||||
|
|
||||||
|
**Model** (`model/types.rs`): `categories: IndexMap<String, Category>`, `data: DataStore`, `formulas: Vec<Formula>`, `views: IndexMap<String, View>`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 12` (regular only). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers.
|
||||||
|
|
||||||
|
**CellKey** (`model/cell.rs`): `Vec<(cat, item)>` — **always sorted by category**. Build with `CellKey::new(coords)` / `with(cat, item)` / `without(cat)`. Never construct the inner `Vec` directly.
|
||||||
|
|
||||||
|
**CellValue**: `Number(f64) | Text(String) | Error(String)`. Errors surface from formula eval (circular, div/0, etc.).
|
||||||
|
|
||||||
|
**DataStore** (`model/cell.rs`): interned symbols + secondary index `(Symbol, Symbol) → {InternedKey}`. Hot-path lookups go through `matching_values` / `matching_cells`.
|
||||||
|
|
||||||
|
**Formula AST** (`improvise-formula/src/ast.rs`):
|
||||||
|
- `Expr { Number, Ref, BinOp, UnaryMinus, Agg, If }`
|
||||||
|
- `BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }`
|
||||||
|
- `AggFunc { Sum, Avg, Min, Max, Count }`
|
||||||
|
- `Formula { raw, target, target_category, expr, filter }`
|
||||||
|
|
||||||
|
**View** (`view/types.rs`): `category_axes: IndexMap<cat, Axis>`, page selections, hidden items, collapsed groups, `number_format`, `prune_empty`. `Axis { Row, Column, Page, None }`. `cycle_axis` rotates Row → Column → Page → None.
|
||||||
|
|
||||||
|
**GridLayout** (`view/layout.rs`): pure function of `Model + View`. `cell_key(r,c)`, `cell_value(r,c)`, `drill_records(r,c)`. **Records mode** auto-detects when `_Index` is on Row and `_Dim` is on Column.
|
||||||
|
|
||||||
|
**AppMode** (`ui/app.rs`): 15 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, ImportWizard, Help, Quit). `SearchMode` is Normal + `search_mode: bool`, not its own variant.
|
||||||
|
|
||||||
|
**Keymap** (`command/keymap.rs`): `Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 14 mode keymaps built by `KeymapSet::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `.improv` File Format
|
||||||
|
|
||||||
|
Plain-text, markdown-like, defined by `persistence/improv.pest`. Parsed by pest — the grammar is the single source of truth (the grammar-walking test generator reads it via `pest_meta`).
|
||||||
|
|
||||||
|
```
|
||||||
|
v2025-04-09
|
||||||
|
# Model Name
|
||||||
|
Initial View: Default
|
||||||
|
|
||||||
|
## View: Default
|
||||||
|
Region: row
|
||||||
|
Measure: column
|
||||||
|
|Time Period|: page, Q1
|
||||||
|
hidden: Region/Internal
|
||||||
|
collapsed: |Time Period|/|2024|
|
||||||
|
format: ,.2f
|
||||||
|
|
||||||
|
## Formulas
|
||||||
|
- Profit = Revenue - Cost # defaults to [_Measure]
|
||||||
|
- Tax = Revenue * 0.1 [CustomCat]
|
||||||
|
|
||||||
|
## Category: Region
|
||||||
|
- North, South, East, West # bare items, comma-separated
|
||||||
|
- Coastal_East[Coastal] # grouped item, one per line
|
||||||
|
> Coastal # group definition
|
||||||
|
|
||||||
|
## Data
|
||||||
|
Region=East, Measure=Revenue = 1200
|
||||||
|
Region=West, Measure=Revenue = |pending| # pipe-quoted text
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Name quoting**: bare `[A-Za-z_][A-Za-z0-9_-]*`, else CL-style `|…|` with escapes `\|`, `\\`, `\n`. Same convention in the formula tokenizer.
|
||||||
|
- **Write order**: Views → Formulas → Categories → Data. Parser tolerates any order.
|
||||||
|
- **Gzip**: `.improv.gz` (same text, gzipped).
|
||||||
|
- **Legacy JSON**: auto-detected by leading `{`; never written.
|
||||||
|
- **Virtual categories**: `_Index`/`_Dim` never persist; `_Measure` persists only non-formula items (formula targets are rebuilt from the `## Formulas` section).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas (read before editing)
|
||||||
|
|
||||||
|
1. **Commands never mutate.** `&CmdContext` is read-only; return `Vec<Box<dyn Effect>>`. If you want to touch `&mut App`, add or extend an Effect.
|
||||||
|
2. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form.
|
||||||
|
3. **No `Model::add_item`.** Go through the category: `m.category_mut("Region").unwrap().add_item("East")`.
|
||||||
|
4. **Virtual categories.** `_Measure` items = explicit items ∪ formula targets. Use `measure_item_names()` / `effective_item_names("_Measure")`. `add_formula` does *not* add items to `_Measure`. Formula-target lookup also falls back into `_Measure` via `find_item_category`.
|
||||||
|
5. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full `f64`. Never feed a rounded value back into eval.
|
||||||
|
6. **Formula eval is fixed-point.** `recompute_formulas(none_cats)` iterates until stable, bounded by `MAX_EVAL_DEPTH`; circular refs converge to `CellValue::Error("circular")`. `App::new` runs it before the first frame so formula cells render on startup.
|
||||||
|
7. **Drill into formula cells** strips the `_Measure` coordinate when it names a formula target, so `matching_cells` returns the raw records that feed the formula instead of returning empty.
|
||||||
|
8. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel.
|
||||||
|
9. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic.
|
||||||
|
10. **Float equality inside formula eval is currently mixed** — `=`/`!=` use a `1e-10` epsilon but the div-by-zero guard checks `rv == 0.0` exactly. Don't assume one or the other; tracked for cleanup.
|
||||||
|
11. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this.
|
||||||
|
12. **Commit paths for cell edits** split across `commit_cell_value` → `commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Inventory
|
||||||
|
|
||||||
|
Line counts are static; test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
|
||||||
|
|
||||||
|
### `improvise-core` (`crates/improvise-core/src/`)
|
||||||
|
```
|
||||||
|
model/types.rs 2062 / 70t Model, formula eval (fixed-point), CRUD
|
||||||
|
model/cell.rs 650 / 28t CellKey (sorted), CellValue, DataStore (interned + index)
|
||||||
|
model/category.rs 222 / 6t Category, Item, Group, CategoryKind
|
||||||
|
model/symbol.rs 79 / 3t Symbol interning
|
||||||
|
view/layout.rs 1140 / 24t GridLayout, drill, records mode
|
||||||
|
view/types.rs 531 / 28t View config (axes, pages, hidden, collapsed, format)
|
||||||
|
view/axis.rs 21 Axis enum
|
||||||
|
workbook.rs 259 / 11t Workbook: Model + cross-view ops
|
||||||
|
format.rs 229 / 29t format_f64, parse_number_format (display only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `improvise-formula` (`crates/improvise-formula/src/`)
|
||||||
|
```
|
||||||
|
parser.rs 776 / 65t pest grammar + tokenizer → Formula AST
|
||||||
|
ast.rs 77 Expr, BinOp, AggFunc, Formula, Filter
|
||||||
|
```
|
||||||
|
|
||||||
|
### `improvise-io` (`crates/improvise-io/src/`)
|
||||||
|
```
|
||||||
|
persistence/improv.pest 124 PEG grammar — single source of truth
|
||||||
|
persistence/mod.rs 2410 / 83t save/load/gzip/legacy-JSON, CSV export
|
||||||
|
import/wizard.rs 1117 / 38t ImportPipeline + ImportWizard
|
||||||
|
import/analyzer.rs 292 / 9t Field kind detection (Category/Measure/Time/Skip)
|
||||||
|
import/csv_parser.rs 300 / 8t CSV parsing, multi-file merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command layer (`src/command/`)
|
||||||
|
```
|
||||||
|
cmd/core.rs 297 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
|
||||||
|
cmd/registry.rs 586 / 0t default_registry() — all registrations (no tests yet)
|
||||||
|
cmd/navigation.rs 475 / 10t Move, EnterAdvance, Page*
|
||||||
|
cmd/cell.rs 198 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
|
||||||
|
cmd/commit.rs 330 / 7t CommitFormula, CommitCategoryAdd/ItemAdd, CommitExport
|
||||||
|
cmd/effect_cmds.rs 437 / 5t effect_cmd! macro, 25+ simple wrappers
|
||||||
|
cmd/grid.rs 409 / 7t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
|
||||||
|
cmd/mode.rs 308 / 8t EnterMode, Quit, EditOrDrill, EnterTileSelect
|
||||||
|
cmd/panel.rs 587 / 13t Panel toggle/cycle/cursor, formula/category/view panels
|
||||||
|
cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
|
||||||
|
cmd/text_buffer.rs 256 / 7t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
|
||||||
|
cmd/tile.rs 160 / 5t MoveTileCursor, TileAxisOp
|
||||||
|
keymap.rs 1066 / 22t KeyPattern, Binding, Keymap, ModeKey, 14 mode keymaps
|
||||||
|
parse.rs 236 / 19t Script/command-line parser (prefix syntax)
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`)
|
||||||
|
```
|
||||||
|
ui/effect.rs 942 / 41t Effect trait, 50+ effect types
|
||||||
|
ui/app.rs 914 / 30t App state, AppMode (15), handle_key, autosave
|
||||||
|
ui/grid.rs 1036 / 13t GridWidget (ratatui), column widths
|
||||||
|
ui/help.rs 617 5-page help overlay (render only)
|
||||||
|
ui/import_wizard_ui.rs 347 Import wizard rendering
|
||||||
|
ui/cat_tree.rs 165 / 6t Category tree flattener for panel
|
||||||
|
draw.rs 400 TUI event loop, frame composition
|
||||||
|
main.rs 391 CLI entry (clap): open, import, cmd, script
|
||||||
|
# other ui/*.rs are small panel renderers — skip unless changing layout/style
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
```
|
||||||
|
examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta)
|
||||||
|
examples/pretty-print.rs Parse stdin, print formatted .improv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context docs
|
||||||
|
```
|
||||||
|
context/design-principles.md Architectural principles & testing doctrine
|
||||||
|
context/plan.md Show HN launch plan
|
||||||
|
context/repo-map.md This file
|
||||||
|
docs/design-notes.md Product vision & non-goals
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
improvise [model.improv] # open TUI (default)
|
||||||
|
improvise import data.csv [--no-wizard] [-o out] # import CSV/JSON
|
||||||
|
improvise cmd 'add-cat Region' -f model.improv # headless command(s)
|
||||||
|
improvise script setup.txt -f model.improv # run script file
|
||||||
|
```
|
||||||
|
|
||||||
|
Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axis`, `--formula`, `--name`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing — the short version
|
||||||
|
|
||||||
|
Full guidance lives in `context/design-principles.md` §6. Quick reminders:
|
||||||
|
|
||||||
|
- Suite runs in <2s. Don't let that drift.
|
||||||
|
- Commands: build a `CmdContext`, call `execute`, assert on returned effects. No terminal needed.
|
||||||
|
- Property tests (`proptest`, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip, `parse(format(parse(generate())))` stability.
|
||||||
|
- **Bug-fix workflow**: write a failing test *before* the fix (regression guard). Document the bug in the test's doc-comment (see `model/types.rs::formula_tests`).
|
||||||
|
- Coverage target ~80% line/branch on logic code; skip ratatui render paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key dependencies
|
||||||
|
|
||||||
|
ratatui 0.30, crossterm 0.28, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.
|
||||||
16
crates/improvise-core/Cargo.toml
Normal file
16
crates/improvise-core/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "improvise-core"
|
||||||
|
version = "0.1.0-rc2"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Pure-data model, views, and workbook for improvise"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
improvise-formula = { path = "../improvise-formula" }
|
||||||
|
anyhow = "1"
|
||||||
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
229
crates/improvise-core/src/format.rs
Normal file
229
crates/improvise-core/src/format.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
|
||||||
|
/// Format a CellValue for display with number formatting options.
|
||||||
|
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||||
|
match v {
|
||||||
|
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||||
|
Some(CellValue::Text(s)) => s.clone(),
|
||||||
|
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a number format string like ",.0" into (use_commas, decimal_places).
|
||||||
|
pub fn parse_number_format(fmt: &str) -> (bool, u8) {
|
||||||
|
let comma = fmt.contains(',');
|
||||||
|
let decimals = fmt
|
||||||
|
.rfind('.')
|
||||||
|
.and_then(|i| fmt[i + 1..].parse::<u8>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(comma, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round half away from zero (the "normal" rounding people expect).
|
||||||
|
fn round_half_away(n: f64, decimals: u8) -> f64 {
|
||||||
|
let factor = 10_f64.powi(decimals as i32);
|
||||||
|
(n * factor + n.signum() * 0.5).trunc() / factor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format an f64 with optional comma grouping and decimal places.
|
||||||
|
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
|
||||||
|
let rounded = round_half_away(n, decimals);
|
||||||
|
let formatted = format!("{:.prec$}", rounded, prec = decimals as usize);
|
||||||
|
if !comma {
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
let (int_part, dec_part) = if let Some(dot) = formatted.find('.') {
|
||||||
|
(&formatted[..dot], Some(&formatted[dot..]))
|
||||||
|
} else {
|
||||||
|
(&formatted[..], None)
|
||||||
|
};
|
||||||
|
let is_neg = int_part.starts_with('-');
|
||||||
|
let digits = if is_neg { &int_part[1..] } else { int_part };
|
||||||
|
let mut result = String::new();
|
||||||
|
for (idx, c) in digits.chars().rev().enumerate() {
|
||||||
|
if idx > 0 && idx % 3 == 0 {
|
||||||
|
result.push(',');
|
||||||
|
}
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
if is_neg {
|
||||||
|
result.push('-');
|
||||||
|
}
|
||||||
|
let mut out: String = result.chars().rev().collect();
|
||||||
|
if let Some(dec) = dec_part {
|
||||||
|
out.push_str(dec);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── parse_number_format ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_comma_and_zero_decimals() {
|
||||||
|
assert_eq!(parse_number_format(",.0"), (true, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_comma_and_two_decimals() {
|
||||||
|
assert_eq!(parse_number_format(",.2"), (true, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_no_comma_two_decimals() {
|
||||||
|
assert_eq!(parse_number_format(".2"), (false, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_comma_only() {
|
||||||
|
assert_eq!(parse_number_format(","), (true, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string() {
|
||||||
|
assert_eq!(parse_number_format(""), (false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_dot_no_digits_after() {
|
||||||
|
// "." has nothing after the dot — parse::<u8> fails → default 0
|
||||||
|
assert_eq!(parse_number_format("."), (false, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multiple_dots_uses_last() {
|
||||||
|
// rfind picks the last dot
|
||||||
|
assert_eq!(parse_number_format(",.1.3"), (true, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_f64 basic ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_no_comma_zero_decimals() {
|
||||||
|
assert_eq!(format_f64(1234.5, false, 0), "1235");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_no_comma_two_decimals() {
|
||||||
|
assert_eq!(format_f64(1234.5, false, 2), "1234.50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_zero_decimals() {
|
||||||
|
assert_eq!(format_f64(1234.0, true, 0), "1,234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_two_decimals() {
|
||||||
|
assert_eq!(format_f64(1234.56, true, 2), "1,234.56");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── comma placement boundaries ─────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_exactly_three_digits() {
|
||||||
|
assert_eq!(format_f64(999.0, true, 0), "999");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_four_digits() {
|
||||||
|
assert_eq!(format_f64(1000.0, true, 0), "1,000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_seven_digits() {
|
||||||
|
assert_eq!(format_f64(1234567.0, true, 0), "1,234,567");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_comma_millions_with_decimals() {
|
||||||
|
assert_eq!(format_f64(1234567.89, true, 2), "1,234,567.89");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── negative numbers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_negative_with_comma() {
|
||||||
|
assert_eq!(format_f64(-1234.0, true, 0), "-1,234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_negative_with_comma_and_decimals() {
|
||||||
|
assert_eq!(format_f64(-1234567.89, true, 2), "-1,234,567.89");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_negative_no_comma() {
|
||||||
|
assert_eq!(format_f64(-42.5, false, 1), "-42.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── edge values ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_zero() {
|
||||||
|
assert_eq!(format_f64(0.0, true, 2), "0.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_small_fraction() {
|
||||||
|
assert_eq!(format_f64(0.123, true, 2), "0.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_negative_small_fraction() {
|
||||||
|
assert_eq!(format_f64(-0.5, true, 1), "-0.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rounding: half-away-from-zero ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_half_up_positive() {
|
||||||
|
// 2.5 → 3, not 2 (banker's would give 2)
|
||||||
|
assert_eq!(format_f64(2.5, false, 0), "3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_half_down_negative() {
|
||||||
|
// -2.5 → -3, not -2 (away from zero)
|
||||||
|
assert_eq!(format_f64(-2.5, false, 0), "-3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_half_at_one_decimal() {
|
||||||
|
// 1.25 → 1.3
|
||||||
|
assert_eq!(format_f64(1.25, false, 1), "1.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_below_half_truncates() {
|
||||||
|
assert_eq!(format_f64(1.24, false, 1), "1.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_above_half_rounds_up() {
|
||||||
|
assert_eq!(format_f64(1.26, false, 1), "1.3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── format_value dispatch ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_number() {
|
||||||
|
let v = CellValue::Number(1234.0);
|
||||||
|
assert_eq!(format_value(Some(&v), true, 0), "1,234");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_text() {
|
||||||
|
let v = CellValue::Text("hello".into());
|
||||||
|
assert_eq!(format_value(Some(&v), true, 2), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_value_none() {
|
||||||
|
assert_eq!(format_value(None, true, 2), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/improvise-core/src/lib.rs
Normal file
12
crates/improvise-core/src/lib.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//! Pure-data core of the `improvise` project: `Model`, views, `Workbook`,
|
||||||
|
//! and number formatting. Depends on `improvise-formula` for AST types;
|
||||||
|
//! has no awareness of UI, I/O, or commands.
|
||||||
|
//!
|
||||||
|
//! Re-exports `improvise_formula` under `formula` so internal code can use
|
||||||
|
//! `crate::formula::*` paths, mirroring the main crate's convention.
|
||||||
|
pub use improvise_formula as formula;
|
||||||
|
|
||||||
|
pub mod format;
|
||||||
|
pub mod model;
|
||||||
|
pub mod view;
|
||||||
|
pub mod workbook;
|
||||||
@ -48,6 +48,38 @@ 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,
|
||||||
|
/// The measure dimension. Items come from two sources: numeric data
|
||||||
|
/// fields (listed in the file) and formula targets (added automatically
|
||||||
|
/// by add_formula). Virtual because formula-derived items are implied
|
||||||
|
/// by the formula definitions — listing them explicitly would be
|
||||||
|
/// redundant in the file format and confusing in the UI.
|
||||||
|
VirtualMeasure,
|
||||||
|
/// High-cardinality per-row field (description, id, note). Stored
|
||||||
|
/// alongside the data so it shows up in record/drill views, but
|
||||||
|
/// defaults to Axis::None and is excluded from pivot limits and the
|
||||||
|
/// auto Row/Column axis assignment.
|
||||||
|
Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CategoryKind {
|
||||||
|
/// True for user-managed pivot dimensions (what the category
|
||||||
|
/// count limit and auto axis assignment apply to).
|
||||||
|
pub fn is_regular(&self) -> bool {
|
||||||
|
matches!(self, CategoryKind::Regular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Category {
|
pub struct Category {
|
||||||
pub id: CategoryId,
|
pub id: CategoryId,
|
||||||
@ -58,6 +90,9 @@ pub struct Category {
|
|||||||
pub groups: Vec<Group>,
|
pub groups: Vec<Group>,
|
||||||
/// Next item id counter
|
/// Next item id counter
|
||||||
next_item_id: ItemId,
|
next_item_id: ItemId,
|
||||||
|
/// Whether this is a regular or virtual category
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: CategoryKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Category {
|
impl Category {
|
||||||
@ -68,9 +103,15 @@ impl Category {
|
|||||||
items: IndexMap::new(),
|
items: IndexMap::new(),
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
next_item_id: 0,
|
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 {
|
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
if let Some(item) = self.items.get(&name) {
|
if let Some(item) = self.items.get(&name) {
|
||||||
@ -82,6 +123,10 @@ impl Category {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remove_item(&mut self, name: &str) {
|
||||||
|
self.items.shift_remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_item_in_group(
|
pub fn add_item_in_group(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: impl Into<String>,
|
name: impl Into<String>,
|
||||||
@ -105,31 +150,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
|
/// Returns item names in order, grouped hierarchically
|
||||||
pub fn ordered_item_names(&self) -> Vec<&str> {
|
pub fn ordered_item_names(&self) -> Vec<&str> {
|
||||||
self.items.keys().map(|s| s.as_str()).collect()
|
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)]
|
#[cfg(test)]
|
||||||
@ -185,30 +209,6 @@ mod tests {
|
|||||||
assert_eq!(c.groups.len(), 1);
|
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]
|
#[test]
|
||||||
fn item_index_reflects_insertion_order() {
|
fn item_index_reflects_insertion_order() {
|
||||||
let mut c = cat();
|
let mut c = cat();
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// A cell key is a sorted vector of (category_name, item_name) pairs.
|
||||||
/// Sorted by category name for canonical form.
|
/// Sorted by category name for canonical form.
|
||||||
@ -41,6 +44,7 @@ impl CellKey {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
|
||||||
partial
|
partial
|
||||||
.iter()
|
.iter()
|
||||||
@ -59,15 +63,21 @@ impl std::fmt::Display for CellKey {
|
|||||||
pub enum CellValue {
|
pub enum CellValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Text(String),
|
Text(String),
|
||||||
|
/// Evaluation error (circular reference, depth overflow, etc.)
|
||||||
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CellValue {
|
impl CellValue {
|
||||||
pub fn as_f64(&self) -> Option<f64> {
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
CellValue::Number(n) => Some(*n),
|
CellValue::Number(n) => Some(*n),
|
||||||
CellValue::Text(_) => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_error(&self) -> bool {
|
||||||
|
matches!(self, CellValue::Error(_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CellValue {
|
impl std::fmt::Display for CellValue {
|
||||||
@ -81,15 +91,27 @@ impl std::fmt::Display for CellValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CellValue::Text(s) => write!(f, "{s}"),
|
CellValue::Text(s) => write!(f, "{s}"),
|
||||||
|
CellValue::Error(msg) => write!(f, "ERR:{msg}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Serialized as a list of (key, value) pairs so CellKey doesn't need
|
||||||
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
/// to implement the `Serialize`-as-string requirement for JSON object keys.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct DataStore {
|
pub struct DataStore {
|
||||||
cells: HashMap<CellKey, CellValue>,
|
/// Primary storage — interned keys, insertion-ordered so records mode
|
||||||
|
/// can display rows in the order they were entered.
|
||||||
|
cells: IndexMap<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 {
|
impl Serialize for DataStore {
|
||||||
@ -97,7 +119,8 @@ impl Serialize for DataStore {
|
|||||||
use serde::ser::SerializeSeq;
|
use serde::ser::SerializeSeq;
|
||||||
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
|
||||||
for (k, v) in &self.cells {
|
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()
|
seq.end()
|
||||||
}
|
}
|
||||||
@ -106,8 +129,11 @@ impl Serialize for DataStore {
|
|||||||
impl<'de> Deserialize<'de> for DataStore {
|
impl<'de> Deserialize<'de> for DataStore {
|
||||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
|
||||||
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
|
let mut store = DataStore::default();
|
||||||
Ok(DataStore { cells })
|
for (key, value) in pairs {
|
||||||
|
store.set(key, value);
|
||||||
|
}
|
||||||
|
Ok(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,27 +142,163 @@ impl DataStore {
|
|||||||
Self::default()
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort cells by their CellKey for deterministic display order.
|
||||||
|
/// Call once on entry into records mode so existing data is ordered;
|
||||||
|
/// subsequent inserts append at the end.
|
||||||
|
pub fn sort_by_key(&mut self) {
|
||||||
|
let symbols = &self.symbols;
|
||||||
|
self.cells.sort_by(|a, _, b, _| {
|
||||||
|
let resolve = |k: &InternedKey| -> Vec<(String, String)> {
|
||||||
|
k.0.iter()
|
||||||
|
.map(|(c, i)| {
|
||||||
|
(
|
||||||
|
symbols.resolve(*c).to_string(),
|
||||||
|
symbols.resolve(*i).to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
resolve(a).cmp(&resolve(b))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set(&mut self, key: CellKey, value: CellValue) {
|
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> {
|
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> {
|
/// Look up an InternedKey for a CellKey without interning new symbols.
|
||||||
&self.cells
|
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) {
|
pub fn remove(&mut self, key: &CellKey) {
|
||||||
self.cells.remove(key);
|
let Some(ikey) = self.lookup_key(key) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.cells.shift_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
|
/// Values of all cells where every coordinate in `partial` matches.
|
||||||
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
|
/// Hot path: avoids allocating CellKey for each result.
|
||||||
self.cells
|
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()
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -285,7 +447,7 @@ mod data_store {
|
|||||||
let k = key(&[("Region", "East")]);
|
let k = key(&[("Region", "East")]);
|
||||||
store.set(k.clone(), CellValue::Number(5.0));
|
store.set(k.clone(), CellValue::Number(5.0));
|
||||||
store.remove(&k);
|
store.remove(&k);
|
||||||
assert!(store.cells().is_empty());
|
assert!(store.iter_cells().next().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
6
crates/improvise-core/src/model/mod.rs
Normal file
6
crates/improvise-core/src/model/mod.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pub mod category;
|
||||||
|
pub mod cell;
|
||||||
|
pub mod symbol;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use types::Model;
|
||||||
79
crates/improvise-core/src/model/symbol.rs
Normal file
79
crates/improvise-core/src/model/symbol.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
2062
crates/improvise-core/src/model/types.rs
Normal file
2062
crates/improvise-core/src/model/types.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ pub enum Axis {
|
|||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
Page,
|
Page,
|
||||||
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Axis {
|
impl std::fmt::Display for Axis {
|
||||||
@ -14,6 +15,7 @@ impl std::fmt::Display for Axis {
|
|||||||
Axis::Row => write!(f, "Row ↕"),
|
Axis::Row => write!(f, "Row ↕"),
|
||||||
Axis::Column => write!(f, "Col ↔"),
|
Axis::Column => write!(f, "Col ↔"),
|
||||||
Axis::Page => write!(f, "Page ☰"),
|
Axis::Page => write!(f, "Page ☰"),
|
||||||
|
Axis::None => write!(f, "None ∅"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1140
crates/improvise-core/src/view/layout.rs
Normal file
1140
crates/improvise-core/src/view/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
7
crates/improvise-core/src/view/mod.rs
Normal file
7
crates/improvise-core/src/view/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pub mod axis;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use axis::Axis;
|
||||||
|
pub use layout::{AxisEntry, GridLayout, synthetic_record_info};
|
||||||
|
pub use types::View;
|
||||||
@ -4,6 +4,10 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use super::axis::Axis;
|
use super::axis::Axis;
|
||||||
|
|
||||||
|
fn default_prune() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct View {
|
pub struct View {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -17,6 +21,9 @@ pub struct View {
|
|||||||
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
pub collapsed_groups: HashMap<String, HashSet<String>>,
|
||||||
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
/// Number format string (e.g. ",.0f" for comma-separated integer)
|
||||||
pub number_format: String,
|
pub number_format: String,
|
||||||
|
/// When true, empty rows/columns are pruned from the display.
|
||||||
|
#[serde(default = "default_prune")]
|
||||||
|
pub prune_empty: bool,
|
||||||
/// Scroll offset for grid
|
/// Scroll offset for grid
|
||||||
pub row_offset: usize,
|
pub row_offset: usize,
|
||||||
pub col_offset: usize,
|
pub col_offset: usize,
|
||||||
@ -33,6 +40,7 @@ impl View {
|
|||||||
hidden_items: HashMap::new(),
|
hidden_items: HashMap::new(),
|
||||||
collapsed_groups: HashMap::new(),
|
collapsed_groups: HashMap::new(),
|
||||||
number_format: ",.0".to_string(),
|
number_format: ",.0".to_string(),
|
||||||
|
prune_empty: false,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
col_offset: 0,
|
col_offset: 0,
|
||||||
selected: (0, 0),
|
selected: (0, 0),
|
||||||
@ -41,20 +49,62 @@ impl View {
|
|||||||
|
|
||||||
pub fn on_category_added(&mut self, cat_name: &str) {
|
pub fn on_category_added(&mut self, cat_name: &str) {
|
||||||
if !self.category_axes.contains_key(cat_name) {
|
if !self.category_axes.contains_key(cat_name) {
|
||||||
// Auto-assign: first → Row, second → Column, rest → Page
|
// Virtual/underscore categories default to Axis::None.
|
||||||
let rows = self.categories_on(Axis::Row).len();
|
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
|
||||||
let cols = self.categories_on(Axis::Column).len();
|
// If a virtual currently holds Row or Column and a regular category needs
|
||||||
let axis = if rows == 0 {
|
// the slot, bump the virtual to None.
|
||||||
|
let axis = if cat_name.starts_with('_') {
|
||||||
|
Axis::None
|
||||||
|
} else {
|
||||||
|
let regular_rows: Vec<String> = self
|
||||||
|
.categories_on(Axis::Row)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
let regular_cols: Vec<String> = self
|
||||||
|
.categories_on(Axis::Column)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| !c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
if regular_rows.is_empty() {
|
||||||
|
// Bump any virtual on Row to None
|
||||||
|
let bump: Vec<String> = self
|
||||||
|
.categories_on(Axis::Row)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
for c in bump {
|
||||||
|
self.category_axes.insert(c, Axis::None);
|
||||||
|
}
|
||||||
Axis::Row
|
Axis::Row
|
||||||
} else if cols == 0 {
|
} else if regular_cols.is_empty() {
|
||||||
|
let bump: Vec<String> = self
|
||||||
|
.categories_on(Axis::Column)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.starts_with('_'))
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
for c in bump {
|
||||||
|
self.category_axes.insert(c, Axis::None);
|
||||||
|
}
|
||||||
Axis::Column
|
Axis::Column
|
||||||
} else {
|
} else {
|
||||||
Axis::Page
|
Axis::Page
|
||||||
|
}
|
||||||
};
|
};
|
||||||
self.category_axes.insert(cat_name.to_string(), axis);
|
self.category_axes.insert(cat_name.to_string(), axis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn on_category_removed(&mut self, cat_name: &str) {
|
||||||
|
self.category_axes.shift_remove(cat_name);
|
||||||
|
self.page_selections.remove(cat_name);
|
||||||
|
self.hidden_items.remove(cat_name);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
|
||||||
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
if let Some(a) = self.category_axes.get_mut(cat_name) {
|
||||||
*a = axis;
|
*a = axis;
|
||||||
@ -71,11 +121,21 @@ impl View {
|
|||||||
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
|
||||||
self.category_axes
|
self.category_axes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, &a)| a == axis)
|
.filter(|&(_, &a)| a == axis)
|
||||||
.map(|(n, _)| n.as_str())
|
.map(|(n, _)| n.as_str())
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owned-string variant of `categories_on(Axis::None)`. Used by callers
|
||||||
|
/// that need to pass the None-axis set to formula recomputation, which
|
||||||
|
/// takes `&[String]` so it can be stored without tying lifetimes to `View`.
|
||||||
|
pub fn none_cats(&self) -> Vec<String> {
|
||||||
|
self.categories_on(Axis::None)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
|
||||||
self.page_selections
|
self.page_selections
|
||||||
.insert(cat_name.to_string(), item.to_string());
|
.insert(cat_name.to_string(), item.to_string());
|
||||||
@ -148,12 +208,13 @@ impl View {
|
|||||||
self.col_offset = 0;
|
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) {
|
pub fn cycle_axis(&mut self, cat_name: &str) {
|
||||||
let next = match self.axis_of(cat_name) {
|
let next = match self.axis_of(cat_name) {
|
||||||
Axis::Row => Axis::Column,
|
Axis::Row => Axis::Column,
|
||||||
Axis::Column => Axis::Page,
|
Axis::Column => Axis::Page,
|
||||||
Axis::Page => Axis::Row,
|
Axis::Page => Axis::None,
|
||||||
|
Axis::None => Axis::Row,
|
||||||
};
|
};
|
||||||
self.set_axis(cat_name, next);
|
self.set_axis(cat_name, next);
|
||||||
self.selected = (0, 0);
|
self.selected = (0, 0);
|
||||||
@ -302,9 +363,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cycle_axis_page_to_row() {
|
fn cycle_axis_page_to_none() {
|
||||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||||
v.cycle_axis("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);
|
assert_eq!(v.axis_of("Time"), Axis::Row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -351,7 +420,7 @@ mod prop_tests {
|
|||||||
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
|
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
|
||||||
let mut v = View::new("T");
|
let mut v = View::new("T");
|
||||||
for c in &cats { v.on_category_added(c); }
|
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 {
|
for c in &cats {
|
||||||
let count = all_axes.iter()
|
let count = all_axes.iter()
|
||||||
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
||||||
@ -377,7 +446,7 @@ mod prop_tests {
|
|||||||
fn set_axis_updates_axis_of(
|
fn set_axis_updates_axis_of(
|
||||||
cats in unique_cat_names(),
|
cats in unique_cat_names(),
|
||||||
target_idx in 0usize..8,
|
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");
|
let mut v = View::new("T");
|
||||||
for c in &cats { v.on_category_added(c); }
|
for c in &cats { v.on_category_added(c); }
|
||||||
@ -392,7 +461,7 @@ mod prop_tests {
|
|||||||
fn set_axis_exclusive(
|
fn set_axis_exclusive(
|
||||||
cats in unique_cat_names(),
|
cats in unique_cat_names(),
|
||||||
target_idx in 0usize..8,
|
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");
|
let mut v = View::new("T");
|
||||||
for c in &cats { v.on_category_added(c); }
|
for c in &cats { v.on_category_added(c); }
|
||||||
266
crates/improvise-core/src/workbook.rs
Normal file
266
crates/improvise-core/src/workbook.rs
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
//! A [`Workbook`] wraps a pure-data [`Model`] with the set of named [`View`]s
|
||||||
|
//! that are rendered over it. Splitting the two breaks the former
|
||||||
|
//! `Model ↔ View` cycle: `Model` knows nothing about views, while `View`
|
||||||
|
//! depends on `Model` (one direction).
|
||||||
|
//!
|
||||||
|
//! Cross-slice operations — adding or removing a category, for example, must
|
||||||
|
//! update both the model's categories and every view's axis assignments
|
||||||
|
//! — live here rather than on `Model`, so `Model` stays pure data and
|
||||||
|
//! `improvise-core` can be extracted without pulling view code along.
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::model::category::CategoryId;
|
||||||
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Workbook {
|
||||||
|
pub model: Model,
|
||||||
|
pub views: IndexMap<String, View>,
|
||||||
|
pub active_view: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workbook {
|
||||||
|
/// Create a new workbook with a fresh `Model` and a single `Default` view.
|
||||||
|
/// Virtual categories (`_Index`, `_Dim`, `_Measure`) are registered on the
|
||||||
|
/// default view. All virtuals default to `Axis::None` via
|
||||||
|
/// `on_category_added` (see improvise-709f2df), then `_Measure` is bumped
|
||||||
|
/// to `Axis::Page` so aggregated pivot views show a single measure at a
|
||||||
|
/// time (see improvise-kos). Leaving `_Index`/`_Dim` on None keeps pivot
|
||||||
|
/// mode the default — records mode activates only when the user moves
|
||||||
|
/// both onto axes.
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
let model = Model::new(name);
|
||||||
|
let mut views = IndexMap::new();
|
||||||
|
views.insert("Default".to_string(), View::new("Default"));
|
||||||
|
let mut wb = Self {
|
||||||
|
model,
|
||||||
|
views,
|
||||||
|
active_view: "Default".to_string(),
|
||||||
|
};
|
||||||
|
for view in wb.views.values_mut() {
|
||||||
|
for cat_name in wb.model.categories.keys() {
|
||||||
|
view.on_category_added(cat_name);
|
||||||
|
}
|
||||||
|
view.set_axis("_Measure", Axis::Page);
|
||||||
|
}
|
||||||
|
wb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-slice category management ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Add a regular pivot category and register it with every view.
|
||||||
|
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
|
let name = name.into();
|
||||||
|
let id = self.model.add_category(&name)?;
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.on_category_added(&name);
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a label category (excluded from pivot-count limit) and register it
|
||||||
|
/// with every view on `Axis::None`.
|
||||||
|
pub fn add_label_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
|
let name = name.into();
|
||||||
|
let id = self.model.add_label_category(&name)?;
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.on_category_added(&name);
|
||||||
|
view.set_axis(&name, Axis::None);
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a category from the model and from every view.
|
||||||
|
pub fn remove_category(&mut self, name: &str) {
|
||||||
|
self.model.remove_category(name);
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.on_category_removed(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active view access ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn active_view(&self) -> &View {
|
||||||
|
self.views
|
||||||
|
.get(&self.active_view)
|
||||||
|
.expect("active_view always names an existing view")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_view_mut(&mut self) -> &mut View {
|
||||||
|
self.views
|
||||||
|
.get_mut(&self.active_view)
|
||||||
|
.expect("active_view always names an existing view")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Create a new view pre-populated with every existing category, and
|
||||||
|
/// return a mutable reference to it. Does not change the active view.
|
||||||
|
pub fn create_view(&mut self, name: impl Into<String>) -> &mut View {
|
||||||
|
let name = name.into();
|
||||||
|
let mut view = View::new(name.clone());
|
||||||
|
for cat_name in self.model.categories.keys() {
|
||||||
|
view.on_category_added(cat_name);
|
||||||
|
}
|
||||||
|
self.views.insert(name.clone(), view);
|
||||||
|
self.views.get_mut(&name).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch_view(&mut self, name: &str) -> Result<()> {
|
||||||
|
if self.views.contains_key(name) {
|
||||||
|
self.active_view = name.to_string();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("View '{name}' not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_view(&mut self, name: &str) -> Result<()> {
|
||||||
|
if self.views.len() <= 1 {
|
||||||
|
return Err(anyhow!("Cannot delete the last view"));
|
||||||
|
}
|
||||||
|
self.views.shift_remove(name);
|
||||||
|
if self.active_view == name {
|
||||||
|
self.active_view = self.views.keys().next().unwrap().clone();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all view scroll offsets to zero. Call after loading or replacing
|
||||||
|
/// a workbook so stale offsets don't render an empty grid.
|
||||||
|
pub fn normalize_view_state(&mut self) {
|
||||||
|
for view in self.views.values_mut() {
|
||||||
|
view.row_offset = 0;
|
||||||
|
view.col_offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Workbook;
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_workbook_has_default_view_with_virtuals_seeded() {
|
||||||
|
let wb = Workbook::new("Test");
|
||||||
|
assert_eq!(wb.active_view, "Default");
|
||||||
|
let v = wb.active_view();
|
||||||
|
// Virtual categories default to Axis::None; _Measure is bumped to Page
|
||||||
|
// so aggregated pivot views show a single measure by default
|
||||||
|
// (improvise-kos, improvise-709f2df).
|
||||||
|
assert_eq!(v.axis_of("_Index"), Axis::None);
|
||||||
|
assert_eq!(v.axis_of("_Dim"), Axis::None);
|
||||||
|
assert_eq!(v.axis_of("_Measure"), Axis::Page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_category_notifies_all_views() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.create_view("Secondary");
|
||||||
|
wb.add_category("Region").unwrap();
|
||||||
|
// Both views should know about Region (axis_of panics on unknown).
|
||||||
|
let _ = wb.views.get("Default").unwrap().axis_of("Region");
|
||||||
|
let _ = wb.views.get("Secondary").unwrap().axis_of("Region");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_label_category_sets_none_axis_on_all_views() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.create_view("Other");
|
||||||
|
wb.add_label_category("Note").unwrap();
|
||||||
|
assert_eq!(wb.views.get("Default").unwrap().axis_of("Note"), Axis::None);
|
||||||
|
assert_eq!(wb.views.get("Other").unwrap().axis_of("Note"), Axis::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_category_removes_from_all_views() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.add_category("Region").unwrap();
|
||||||
|
wb.create_view("Second");
|
||||||
|
wb.remove_category("Region");
|
||||||
|
// Region should no longer appear in either view's Row axis.
|
||||||
|
assert!(
|
||||||
|
wb.views
|
||||||
|
.get("Default")
|
||||||
|
.unwrap()
|
||||||
|
.categories_on(Axis::Row)
|
||||||
|
.iter()
|
||||||
|
.all(|c| *c != "Region")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
wb.views
|
||||||
|
.get("Second")
|
||||||
|
.unwrap()
|
||||||
|
.categories_on(Axis::Row)
|
||||||
|
.iter()
|
||||||
|
.all(|c| *c != "Region")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_view_changes_active_view() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.create_view("Other");
|
||||||
|
wb.switch_view("Other").unwrap();
|
||||||
|
assert_eq!(wb.active_view, "Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_view_unknown_returns_error() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
assert!(wb.switch_view("NoSuchView").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_view_removes_it() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.create_view("Extra");
|
||||||
|
wb.delete_view("Extra").unwrap();
|
||||||
|
assert!(!wb.views.contains_key("Extra"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_last_view_returns_error() {
|
||||||
|
let wb = Workbook::new("Test");
|
||||||
|
// Use wb without binding mut — delete_view would need &mut, so:
|
||||||
|
let mut wb = wb;
|
||||||
|
assert!(wb.delete_view("Default").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_active_view_switches_to_another() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.create_view("Other");
|
||||||
|
wb.switch_view("Other").unwrap();
|
||||||
|
wb.delete_view("Other").unwrap();
|
||||||
|
assert_ne!(wb.active_view, "Other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_category_goes_to_row_second_to_column_rest_to_page() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.add_category("Region").unwrap();
|
||||||
|
wb.add_category("Product").unwrap();
|
||||||
|
wb.add_category("Time").unwrap();
|
||||||
|
let v = wb.active_view();
|
||||||
|
assert_eq!(v.axis_of("Region"), Axis::Row);
|
||||||
|
assert_eq!(v.axis_of("Product"), Axis::Column);
|
||||||
|
assert_eq!(v.axis_of("Time"), Axis::Page);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_view_copies_category_structure() {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.add_category("Region").unwrap();
|
||||||
|
wb.add_category("Product").unwrap();
|
||||||
|
wb.create_view("Secondary");
|
||||||
|
let v = wb.views.get("Secondary").unwrap();
|
||||||
|
let _ = v.axis_of("Region");
|
||||||
|
let _ = v.axis_of("Product");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/improvise-formula/Cargo.toml
Normal file
17
crates/improvise-formula/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "improvise-formula"
|
||||||
|
version = "0.1.0-rc2"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Formula parser and AST for improvise"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
pest = "2.8.6"
|
||||||
|
pest_derive = "2.8.6"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pest_meta = "2.8.6"
|
||||||
|
proptest = "1"
|
||||||
91
crates/improvise-formula/src/formula.pest
Normal file
91
crates/improvise-formula/src/formula.pest
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Formula grammar for improvise.
|
||||||
|
//
|
||||||
|
// A formula has the form: TARGET = EXPR [WHERE filter]
|
||||||
|
// See parser.rs for the tree walker that produces a Formula AST.
|
||||||
|
//
|
||||||
|
// Identifier rules (bare_ident / pipe_quoted) mirror `bare_name` and
|
||||||
|
// `pipe_quoted` in src/persistence/improv.pest: bare identifiers are
|
||||||
|
// alphanumeric plus `_` and `-`, with no internal spaces; multi-word
|
||||||
|
// names must be pipe-quoted.
|
||||||
|
|
||||||
|
// Auto-skip horizontal whitespace between tokens in non-atomic rules.
|
||||||
|
WHITESPACE = _{ " " | "\t" }
|
||||||
|
|
||||||
|
// ---- top-level ----------------------------------------------------------
|
||||||
|
|
||||||
|
formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI }
|
||||||
|
|
||||||
|
// The target keeps its raw text (including pipes, if any) — we capture
|
||||||
|
// the span directly rather than walking into its children.
|
||||||
|
target = { identifier }
|
||||||
|
|
||||||
|
where_clause = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
|
||||||
|
|
||||||
|
// ---- expressions --------------------------------------------------------
|
||||||
|
|
||||||
|
// Used by parse_expr() — forces a standalone expression to consume the
|
||||||
|
// whole input, so `1 + 2 3` fails instead of silently dropping " 3".
|
||||||
|
expr_eoi = { SOI ~ expr ~ EOI }
|
||||||
|
|
||||||
|
expr = { add_expr }
|
||||||
|
|
||||||
|
add_expr = { mul_expr ~ (add_op ~ mul_expr)* }
|
||||||
|
add_op = { "+" | "-" }
|
||||||
|
|
||||||
|
mul_expr = { pow_expr ~ (mul_op ~ pow_expr)* }
|
||||||
|
mul_op = { "*" | "/" }
|
||||||
|
|
||||||
|
pow_expr = { unary ~ (pow_op ~ unary)? }
|
||||||
|
pow_op = { "^" }
|
||||||
|
|
||||||
|
unary = { unary_minus | primary }
|
||||||
|
unary_minus = { "-" ~ primary }
|
||||||
|
|
||||||
|
primary = {
|
||||||
|
number
|
||||||
|
| agg_call
|
||||||
|
| if_expr
|
||||||
|
| paren_expr
|
||||||
|
| ref_expr
|
||||||
|
}
|
||||||
|
|
||||||
|
paren_expr = { "(" ~ expr ~ ")" }
|
||||||
|
|
||||||
|
// Aggregates with optional inline WHERE filter inside the parens.
|
||||||
|
agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" }
|
||||||
|
agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" }
|
||||||
|
inline_where = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
|
||||||
|
|
||||||
|
// IF(cond, then, else). Comparison is a standalone rule because comparison
|
||||||
|
// operators are not valid in general expressions — only inside an IF condition.
|
||||||
|
if_expr = { ^"IF" ~ "(" ~ comparison ~ "," ~ expr ~ "," ~ expr ~ ")" }
|
||||||
|
comparison = { expr ~ cmp_op ~ expr }
|
||||||
|
cmp_op = { "!=" | "<=" | ">=" | "<" | ">" | "=" }
|
||||||
|
|
||||||
|
// A reference to an item. `SUM` and `IF` without parens fall through to
|
||||||
|
// this rule because agg_call / if_expr require a "(" and otherwise fail.
|
||||||
|
ref_expr = { identifier }
|
||||||
|
|
||||||
|
// ---- identifiers --------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Mirror of improv.pest's bare_name / pipe_quoted.
|
||||||
|
|
||||||
|
identifier = ${ pipe_quoted | bare_ident }
|
||||||
|
|
||||||
|
// Backslash escapes inside pipes: \| literal pipe, \\ backslash, \n newline.
|
||||||
|
pipe_quoted = @{ "|" ~ ("\\" ~ ANY | !"|" ~ ANY)* ~ "|" }
|
||||||
|
|
||||||
|
bare_ident = @{
|
||||||
|
(ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")*
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- literal values -----------------------------------------------------
|
||||||
|
|
||||||
|
filter_value = { string | pipe_quoted | bare_ident }
|
||||||
|
|
||||||
|
string = @{ "\"" ~ (!"\"" ~ ANY)* ~ "\"" }
|
||||||
|
|
||||||
|
number = @{
|
||||||
|
ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT*)?
|
||||||
|
| "." ~ ASCII_DIGIT+
|
||||||
|
}
|
||||||
1080
crates/improvise-formula/src/parser.rs
Normal file
1080
crates/improvise-formula/src/parser.rs
Normal file
File diff suppressed because it is too large
Load Diff
25
crates/improvise-io/Cargo.toml
Normal file
25
crates/improvise-io/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "improvise-io"
|
||||||
|
version = "0.1.0-rc2"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Persistence and import for improvise (.improv format, CSV, JSON wizard)"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://github.com/fiddlerwoaroof/improvise"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
improvise-core = { path = "../improvise-core" }
|
||||||
|
improvise-formula = { path = "../improvise-formula" }
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
csv = "1"
|
||||||
|
flate2 = "1"
|
||||||
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
|
pest = "2.8.6"
|
||||||
|
pest_derive = "2.8.6"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pest_meta = "2.8.6"
|
||||||
|
proptest = "1"
|
||||||
|
tempfile = "3"
|
||||||
292
crates/improvise-io/src/import/analyzer.rs
Normal file
292
crates/improvise-io/src/import/analyzer.rs
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FieldKind {
|
||||||
|
/// Small number of distinct string values → dimension/category
|
||||||
|
Category,
|
||||||
|
/// Numeric values → measure
|
||||||
|
Measure,
|
||||||
|
/// Date/time strings → time category
|
||||||
|
TimeCategory,
|
||||||
|
/// Many unique strings (IDs, names) → label/identifier
|
||||||
|
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 {
|
||||||
|
pub fn kind_label(&self) -> &'static str {
|
||||||
|
match self.kind {
|
||||||
|
FieldKind::Category => "Category (dimension)",
|
||||||
|
FieldKind::Measure => "Measure (numeric)",
|
||||||
|
FieldKind::TimeCategory => "Time Category",
|
||||||
|
FieldKind::Label => "Label (per-row, drill-view only)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
if records.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all field names
|
||||||
|
let mut fields: Vec<String> = Vec::new();
|
||||||
|
for record in records {
|
||||||
|
if let Value::Object(map) = record {
|
||||||
|
for key in map.keys() {
|
||||||
|
if !fields.contains(key) {
|
||||||
|
fields.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|field| {
|
||||||
|
let values: Vec<&Value> = records.iter().filter_map(|r| r.get(&field)).collect();
|
||||||
|
|
||||||
|
let all_numeric = values.iter().all(|v| v.is_number());
|
||||||
|
let all_string = values.iter().all(|v| v.is_string());
|
||||||
|
|
||||||
|
if all_numeric {
|
||||||
|
return FieldProposal {
|
||||||
|
field,
|
||||||
|
kind: FieldKind::Measure,
|
||||||
|
distinct_values: vec![],
|
||||||
|
accepted: true,
|
||||||
|
date_format: None,
|
||||||
|
date_components: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_string {
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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 date_format.is_some() {
|
||||||
|
return FieldProposal {
|
||||||
|
field,
|
||||||
|
kind: FieldKind::TimeCategory,
|
||||||
|
distinct_values: distinct_vec,
|
||||||
|
accepted: true,
|
||||||
|
date_format,
|
||||||
|
date_components: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if n <= CATEGORY_THRESHOLD {
|
||||||
|
return FieldProposal {
|
||||||
|
field,
|
||||||
|
kind: FieldKind::Category,
|
||||||
|
distinct_values: distinct_vec,
|
||||||
|
accepted: true,
|
||||||
|
date_format: None,
|
||||||
|
date_components: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return FieldProposal {
|
||||||
|
field,
|
||||||
|
kind: FieldKind::Label,
|
||||||
|
distinct_values: distinct_vec,
|
||||||
|
accepted: true,
|
||||||
|
date_format: None,
|
||||||
|
date_components: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed or other: treat as label
|
||||||
|
FieldProposal {
|
||||||
|
field,
|
||||||
|
kind: FieldKind::Label,
|
||||||
|
distinct_values: vec![],
|
||||||
|
accepted: true,
|
||||||
|
date_format: None,
|
||||||
|
date_components: vec![],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract nested array from JSON by dot-path
|
||||||
|
pub fn extract_array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Vec<Value>> {
|
||||||
|
if path.is_empty() {
|
||||||
|
return value.as_array();
|
||||||
|
}
|
||||||
|
let mut current = value;
|
||||||
|
for part in path.split('.') {
|
||||||
|
current = current.get(part)?;
|
||||||
|
}
|
||||||
|
current.as_array()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find candidate paths to arrays in JSON
|
||||||
|
pub fn find_array_paths(value: &Value) -> Vec<String> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
find_array_paths_inner(value, "", &mut paths);
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>) {
|
||||||
|
match value {
|
||||||
|
Value::Array(_) => {
|
||||||
|
paths.push(prefix.to_string());
|
||||||
|
}
|
||||||
|
Value::Object(map) => {
|
||||||
|
for (key, val) in map {
|
||||||
|
let path = if prefix.is_empty() {
|
||||||
|
key.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}.{key}")
|
||||||
|
};
|
||||||
|
find_array_paths_inner(val, &path, paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
300
crates/improvise-io/src/import/csv_parser.rs
Normal file
300
crates/improvise-io/src/import/csv_parser.rs
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RFC 4180 edge cases ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc4180_embedded_comma_in_quoted_field() {
|
||||||
|
let (path, _dir) =
|
||||||
|
create_temp_csv("Name,Address,Value\n\"Smith, John\",\"123 Main St, Apt 4\",100");
|
||||||
|
let records = parse_csv(&path).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(records[0]["Name"], Value::String("Smith, John".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
records[0]["Address"],
|
||||||
|
Value::String("123 Main St, Apt 4".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc4180_escaped_quotes_in_field() {
|
||||||
|
// RFC 4180: doubled quotes ("") inside a quoted field represent a literal quote
|
||||||
|
let (path, _dir) =
|
||||||
|
create_temp_csv("Name,Description,Value\nWidget,\"A \"\"great\"\" product\",10");
|
||||||
|
let records = parse_csv(&path).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
records[0]["Description"],
|
||||||
|
Value::String("A \"great\" product".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc4180_newline_in_quoted_field() {
|
||||||
|
// RFC 4180: quoted fields may contain newlines
|
||||||
|
let (path, _dir) = create_temp_csv("Name,Notes,Value\n\"Widget\",\"Line 1\nLine 2\",10");
|
||||||
|
let records = parse_csv(&path).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
records[0]["Notes"],
|
||||||
|
Value::String("Line 1\nLine 2".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc4180_embedded_comma_and_quotes_combined() {
|
||||||
|
let (path, _dir) =
|
||||||
|
create_temp_csv("Name,Desc\n\"Smith, \"\"Jr.\"\"\",\"Said \"\"hello, world\"\"\"");
|
||||||
|
let records = parse_csv(&path).unwrap();
|
||||||
|
assert_eq!(records.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
records[0]["Name"],
|
||||||
|
Value::String("Smith, \"Jr.\"".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
records[0]["Desc"],
|
||||||
|
Value::String("Said \"hello, world\"".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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
pub mod analyzer;
|
pub mod analyzer;
|
||||||
|
pub mod csv_parser;
|
||||||
pub mod wizard;
|
pub mod wizard;
|
||||||
1121
crates/improvise-io/src/import/wizard.rs
Normal file
1121
crates/improvise-io/src/import/wizard.rs
Normal file
File diff suppressed because it is too large
Load Diff
16
crates/improvise-io/src/lib.rs
Normal file
16
crates/improvise-io/src/lib.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//! I/O layer for `improvise`: `.improv` persistence (parse/format, save/load,
|
||||||
|
//! CSV export) and import (CSV/JSON wizard, field analyzer).
|
||||||
|
//!
|
||||||
|
//! Depends on `improvise-core` for the data model (`Model`, `View`,
|
||||||
|
//! `Workbook`, `CellKey`, `CellValue`, `GridLayout`, `Axis`, `Group`) and on
|
||||||
|
//! `improvise-formula` for formula parsing. Has no awareness of UI or
|
||||||
|
//! commands — builds standalone via `cargo build -p improvise-io`.
|
||||||
|
//!
|
||||||
|
//! Re-exports the core modules under their conventional names so code in
|
||||||
|
//! this crate can keep using `crate::model::*`, `crate::view::*`,
|
||||||
|
//! `crate::workbook::*`, `crate::format::*`, and `crate::formula::*` paths.
|
||||||
|
pub use improvise_core::{format, model, view, workbook};
|
||||||
|
pub use improvise_formula as formula;
|
||||||
|
|
||||||
|
pub mod import;
|
||||||
|
pub mod persistence;
|
||||||
123
crates/improvise-io/src/persistence/improv.pest
Normal file
123
crates/improvise-io/src/persistence/improv.pest
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// ── .improv file grammar (v2025-04-09) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Line-oriented, markdown-flavoured format for multi-dimensional models.
|
||||||
|
// Sections may appear in any order.
|
||||||
|
//
|
||||||
|
// Names: bare alphanumeric or pipe-quoted |like this|.
|
||||||
|
// Inside pipes, backslash escapes: \| for literal pipe, \\ for backslash,
|
||||||
|
// \n for newline.
|
||||||
|
// Values: pipe-quoted |text| or bare numbers.
|
||||||
|
|
||||||
|
file = {
|
||||||
|
SOI ~
|
||||||
|
version_line ~
|
||||||
|
model_name ~
|
||||||
|
initial_view? ~
|
||||||
|
section* ~
|
||||||
|
EOI
|
||||||
|
}
|
||||||
|
|
||||||
|
version_line = { "v2025-04-09" ~ NEWLINE ~ blank_lines }
|
||||||
|
model_name = { "# " ~ rest_of_line ~ NEWLINE ~ blank_lines }
|
||||||
|
initial_view = { "Initial View: " ~ rest_of_line ~ NEWLINE ~ blank_lines }
|
||||||
|
|
||||||
|
section = _{
|
||||||
|
category_section
|
||||||
|
| formulas_section
|
||||||
|
| data_section
|
||||||
|
| view_section
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
category_section = {
|
||||||
|
"## Category: " ~ rest_of_line ~ NEWLINE ~ blank_lines ~
|
||||||
|
category_entry*
|
||||||
|
}
|
||||||
|
|
||||||
|
category_entry = _{ group_hierarchy | grouped_item | item_list }
|
||||||
|
|
||||||
|
// Comma-separated bare items (no group): `- Food, Gas, Total`
|
||||||
|
item_list = {
|
||||||
|
"- " ~ name ~ ("," ~ " "* ~ name)* ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single item with group bracket: `- Jan[Q1]`
|
||||||
|
grouped_item = {
|
||||||
|
"- " ~ name ~ "[" ~ name ~ "]" ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
group_hierarchy = {
|
||||||
|
"> " ~ name ~ "[" ~ name ~ "]" ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formulas ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
formulas_section = {
|
||||||
|
"## Formulas" ~ NEWLINE ~ blank_lines ~
|
||||||
|
formula_line*
|
||||||
|
}
|
||||||
|
|
||||||
|
formula_line = {
|
||||||
|
"- " ~ rest_of_line ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data_section = {
|
||||||
|
"## Data" ~ NEWLINE ~ blank_lines ~
|
||||||
|
data_line*
|
||||||
|
}
|
||||||
|
|
||||||
|
data_line = {
|
||||||
|
coord_list ~ " = " ~ cell_value ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
coord_list = { coord ~ (", " ~ coord)* }
|
||||||
|
coord = { name ~ "=" ~ name }
|
||||||
|
|
||||||
|
cell_value = _{ number | pipe_quoted | bare_value }
|
||||||
|
|
||||||
|
number = @{
|
||||||
|
"-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? ~ (("e" | "E") ~ ("+" | "-")? ~ ASCII_DIGIT+)?
|
||||||
|
}
|
||||||
|
|
||||||
|
bare_value = @{ (!NEWLINE ~ ANY)+ }
|
||||||
|
|
||||||
|
// ── View ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
view_section = {
|
||||||
|
"## View: " ~ rest_of_line ~ NEWLINE ~ blank_lines ~
|
||||||
|
view_entry*
|
||||||
|
}
|
||||||
|
|
||||||
|
view_entry = _{ format_line | hidden_line | collapsed_line | axis_line }
|
||||||
|
|
||||||
|
axis_line = {
|
||||||
|
name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
axis_kind = @{ "row" | "column" | "page" | "none" }
|
||||||
|
|
||||||
|
format_line = { "format: " ~ rest_of_line ~ NEWLINE ~ blank_lines }
|
||||||
|
hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
|
||||||
|
collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines }
|
||||||
|
|
||||||
|
// ── Names ────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A name is either pipe-quoted or a bare identifier.
|
||||||
|
// Pipe-quoted: |Income, Gross| — backslash escapes inside:
|
||||||
|
// \| = literal pipe, \\ = literal backslash, \n = newline
|
||||||
|
// Bare: no = , | [ ] / : # or newlines.
|
||||||
|
|
||||||
|
name = _{ pipe_quoted | bare_name }
|
||||||
|
|
||||||
|
pipe_quoted = { "|" ~ pipe_inner ~ "|" }
|
||||||
|
pipe_inner = @{ ("\\" ~ ANY | !"|" ~ ANY)* }
|
||||||
|
|
||||||
|
bare_name = @{ ('A'..'Z' | 'a'..'z' | "_") ~ ('A'..'Z' | 'a'..'z' | '0'..'9' | "_" | "-")* }
|
||||||
|
|
||||||
|
// ── Shared ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
rest_of_line = @{ (!NEWLINE ~ ANY)* }
|
||||||
|
blank_lines = _{ NEWLINE* }
|
||||||
2402
crates/improvise-io/src/persistence/mod.rs
Normal file
2402
crates/improvise-io/src/persistence/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
17
dist-workspace.toml
Normal file
17
dist-workspace.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["cargo:."]
|
||||||
|
|
||||||
|
# Config for 'dist'
|
||||||
|
[dist]
|
||||||
|
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
|
||||||
|
cargo-dist-version = "0.30.4"
|
||||||
|
# CI backends to support
|
||||||
|
ci = "github"
|
||||||
|
# The installers to generate for each app
|
||||||
|
installers = ["shell"]
|
||||||
|
# Target platforms to build apps for (Rust target-triple syntax)
|
||||||
|
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu"]
|
||||||
|
# Path that installers should place binaries in
|
||||||
|
install-path = "CARGO_HOME"
|
||||||
|
# Whether to install an updater program
|
||||||
|
install-updater = false
|
||||||
655
docs/casts/drill.cast
Normal file
655
docs/casts/drill.cast
Normal file
@ -0,0 +1,655 @@
|
|||||||
|
{"version": 2, "width": 120, "height": 37, "timestamp": 1775772329, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}}
|
||||||
|
[0.255122, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[0.26349, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[0.389731, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:05:29\r\n16025:% \u001b[K"]
|
||||||
|
[0.389839, "o", "\u001b[?2004h"]
|
||||||
|
[3.260628, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"]
|
||||||
|
[4.100025, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l"]
|
||||||
|
[4.10015, "o", "\r\r\n"]
|
||||||
|
[4.101098, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"]
|
||||||
|
[4.137227, "o", "\u001b[?1049h"]
|
||||||
|
[4.139474, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"]
|
||||||
|
[4.139536, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[4.139643, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"]
|
||||||
|
[4.139729, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"]
|
||||||
|
[4.139761, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.242195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.344691, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.447079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.549689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.652179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.754674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.856161, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.958622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.060824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.163294, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.265591, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.367483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.389269, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[5.389472, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "]
|
||||||
|
[5.389587, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.491141, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.592963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.69456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.797252, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.899269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.001855, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.102804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.204488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.306416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.408174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.515144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.614322, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.715087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.816824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.919456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.023808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.126353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.227706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.329812, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.431498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.533584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.635475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.738315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.840254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.942468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.044291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.049255, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.151473, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.252708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.354391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.456511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.558371, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.66005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.761856, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.839104, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.941054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.043964, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.146777, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.24872, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.29841, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.399634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.500481, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.602021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.704492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.76467, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.866679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.96862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.070632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.168973, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.271432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.373277, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.475256, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.54583, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.647847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.750326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.851916, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.904656, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.007234, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.108949, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.210797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.305394, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.40791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.509599, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.612098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.714734, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.817078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.837581, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;20H\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;19H\u001b[3m2,940 4,200 \u001b[8;34H1,260\u001b[23m \u001b[9;2H\u001b[38;5;8;49m────────────"]
|
||||||
|
[11.837774, "o", "──────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[22m\u001b[39;49m \u001b[11;2H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HN\u001b[37;25Hne \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.939188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.040703, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.142444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.243643, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.344543, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.44606, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.546854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.648382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.749506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.85104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.952602, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.037101, "o", "\u001b[36;103H\u001b[38;5;8;49m [Date_Month ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.1401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.241851, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.343997, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.445753, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.547755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.648763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.750013, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.851891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.953275, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.055126, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.15682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.258532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.359739, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.46102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.562788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.664633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.766446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.868114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.969328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.070964, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.172609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.274437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.376279, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.477908, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.579437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.624853, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HCost | Customer = Stark Enterprises | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[4;25H\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-03-25 Gadgets East 2025-03 5670\u001b[7;2H\u001b[39;49m1 Stark En\u001b[7;16Herprises 2025-01-31 Gadgets\u001b[7;46HEast\u001b[7;54H2025-01\u001b[7;63H5180\u001b[8;2H \u001b[8;12H \u001b[8;17H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;12H\u001b[38;5;0;48;5;8mrilled into\u001b[37;24Hcell:\u001b[37;30H2\u001b[37;32Hrows\u001b[37;110H _Drill\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.726365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.827956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.929775, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.031367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.132934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.234738, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.335777, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.436366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.53795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.639488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.74103, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.843208, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.944693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.046398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.148618, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.250351, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.351896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.453688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.555582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.656954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.758333, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.859977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.961612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.062956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.164668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.26625, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.367619, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.46896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.570702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.672674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.774225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.875264, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.976852, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.077943, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.179556, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.280996, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.382478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.422995, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises] \u001b[39;49m \u001b[4;6H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[4;25H\u001b[24mRevenue Profit\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets Eas\u001b[7;16H \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[7;46H \u001b[7;54H \u001b[7;63H \u001b[8;2HSprockets\u001b[8;12HEast\u001b[8;17H\u001b[3m 2,940 4,200 1,260\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[36;10H\u001b[22m\u001b[38;5;8"]
|
||||||
|
[19.42323, "o", ";49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.52534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.627683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.729242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.83102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.932257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.033895, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.135888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.237369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.288716, "o", "\u001b[4;17H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;17H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.390112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.491486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.593008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.693621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.795131, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.896129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.96272, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HRevenue | Customer\u001b[3;34H= Stark Enterprises | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-01-31 Gadgets East 2025-01 7400\u001b[7;2H\u001b[39;49m1 Stark En\u001b[7;16Herprises 2025-03-25 Gadgets\u001b[7;46HEast\u001b[7;54H2025-03\u001b[7;63H8100\u001b[8;2H \u001b[8;12H \u001b[8;17H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;112H\u001b[38;5;0;48;5;8m _Dri\u001b[37;118Hl\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.064326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.165838, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.26736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.369255, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.470736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.540131, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-01-31\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.641991, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.743614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.844468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.894452, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01-31\u001b[1m\u001b[38;5;0;48;5;6m Gadgets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.996018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.097619, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.188952, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Gadgets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.290866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.392364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.483448, "o", "\u001b[4;43H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;43H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-01\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.585324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.686841, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.788996, "o", "\u001b[4;50H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;50H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-01\u001b[1m\u001b[38;5;0;48;5;6m 7400\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.890257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.99175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.093257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.193931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.294717, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.36863, "o", "\u001b[6;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m7400 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 7400▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.470497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.571891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.673583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.775236, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.877092, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.978622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.080177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.182019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.283573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.385055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.48658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.587937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.617837, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.719109, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.820616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.922015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.023349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.12483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.141478, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.243544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.344981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.445341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.499384, "o", "\u001b[6;62H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.601098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.702607, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.804164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.905108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.953026, "o", "\u001b[6;62H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.053947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.118277, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.218892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.293284, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.394821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.496143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.597957, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.69913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.800668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.834513, "o", "\u001b[6;2H0 Stark Enterprises 2025-01-31 Gadgets East 2025-01 7500 \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237m1 \u001b[22m\u001b[38;5;15;48;5;237m Stark Enterprises 2025-03-25 Gadgets East 2025-03\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[22m\u001b[24m\u001b[39;48;5;237m \u001b[37;7H\u001b[1m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.935864, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.037078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.140586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.241716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.342374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.44323, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.544218, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.645819, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.747314, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.848602, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.950053, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.051416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.153119, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.158637, "o", "\u001b[7;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m<\u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m<▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.260014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.360707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.462516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.563782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.665631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.767546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.869222, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.970857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.072476, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.173862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.275017, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.37639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.477963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.579372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.681267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.782127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.791533, "o", "\u001b[7;61H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.893075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.994708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.09607, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.197242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.297699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.383851, "o", "\u001b[7;61H\u001b[1m\u001b[38;5;0;48;5;6m 8100\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Drilled into cell: 2 rows _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.485658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.587581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.688828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.790416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.892205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.993716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.095342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.196814, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.235231, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises]\u001b[3;34H\u001b[39;49m \u001b[4;6H \u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[24m Profit\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,600\u001b[22m\u001b[38;5;15;48;5;237m 4,750\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;12HEast\u001b[8;17H\u001b[3m 2,940 4,200 1,260\u001b[9;2H\u001b[23m\u001b[38;5;8;49m─────────────────────────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[31.23528, "o", "─────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,100 15,730\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.33715, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.438167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.539656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.641338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.742963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.844469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.945935, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.04757, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.148996, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.250673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.352507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.454665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.556091, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.611975, "o", "\u001b[6;2HGadgets East \u001b[3m 10,850 15,600 4,750\u001b[23m \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mWidgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 14,580\u001b[1m\u001b[38;5;0;48;5;6m 24,300\u001b[22m\u001b[38;5;15;48;5;237m 9,720\u001b[23m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.714152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.816243, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.91789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.02012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.121571, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.223001, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.324736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.426455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.527682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.629295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.730933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.832574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.934164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.009081, "o", "\u001b[7;2HWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2H\u001b[1m\u001b[38;5;6;48;5;237mSprockets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 2,940\u001b[1m\u001b[38;5;0;48;5;6m 4,200\u001b[22m\u001b[38;5;15;48;5;237m 1,260\u001b[23m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.111055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.21937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.321025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.422024, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.523707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.625411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.726664, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.828292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.929129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.030341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.13176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.23305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.334358, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.435652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.518974, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HRevenue | Customer\u001b[3;34H= Stark Enterprises | Product = Sprockets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date Product Region Date_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-02-20 Sprockets East 2025-02 4200\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m \u001b[7;12H \u001b[7;17H \u001b[8;2H \u001b[9;2H \u001b[10;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;30H\u001b[38;5;0;48;5;8m1\u001b[37;112H _Dri\u001b[3"]
|
||||||
|
[35.519, "o", "7;118Hl\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.620559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.722169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.823642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.924946, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.02612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.127752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.228896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.330381, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.432324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.534209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.635534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.726876, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.828227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.9292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.02562, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[1m\u001b[38;5;0;48;5;6m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.126867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.227776, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.243248, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Sprockets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.34447, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.437218, "o", "\u001b[4;45H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;45H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.538942, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.640498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.717318, "o", "\u001b[4;52H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;52H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.818793, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.920394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.021784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.122547, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.22418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.325665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.427127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.528763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.630238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.685239, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4200 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 4200▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.786586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.888352, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.989767, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.091211, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.19311, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.294318, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.395029, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.49683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.59827, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.699745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.801287, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.902386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.003913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.105799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.206374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.307735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.409433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.510898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.612475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.713387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.815573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.824365, "o", "\u001b[6;63H\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.926091, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.027453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.129047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.229876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.33153, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.4335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.535434, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.637062, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.738676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.840484, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.943155, "o", "\u001b[4;52H\u001b[1m\u001b[4m\u001b[38;5;3;49m Date_Month\u001b[24m Value\u001b[6;52H\u001b[38;5;0;48;5;6m 2025-02\u001b[22m\u001b[38;5;15;48;5;237m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.04455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.147113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.248907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.350271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.452008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.454049, "o", "\u001b[4;45H\u001b[1m\u001b[4m\u001b[38;5;3;49m Region\u001b[24m Date_Month\u001b[6;45H\u001b[38;5;0;48;5;6m East\u001b[22m\u001b[38;5;15;48;5;237m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.555938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.656672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.694792, "o", "\u001b[4;35H\u001b[1m\u001b[4m\u001b[38;5;3;49m Product\u001b[24m Region\u001b[6;35H\u001b[38;5;0;48;5;6m Sprockets\u001b[22m\u001b[38;5;15;48;5;237m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.796342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.89699, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.962391, "o", "\u001b[4;24H\u001b[1m\u001b[4m\u001b[38;5;3;49m Date\u001b[24m Product\u001b[6;24H\u001b[38;5;0;48;5;6m 2025-02-20\u001b[22m\u001b[38;5;15;48;5;237m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.06307, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.164443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.207856, "o", "\u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date\u001b[6;6H\u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.308944, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.4106, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.512276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.613521, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.715215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.734956, "o", "\u001b[6;6H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6mStark Enterprises \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: Stark Enterprises▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.836886, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.938724, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.040338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.142082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.243479, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.345158, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.446823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.548419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.650021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.751645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.853167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.954401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.056002, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.157732, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.258617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.306905, "o", "\u001b[6;6H\u001b[1m\u001b[38;5;0;48;5;6m Stark Enterprises\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.40892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.510835, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.612239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.71417, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.816108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.917769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.019473, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.120249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.221745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.32364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.425537, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.528656, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.630009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.730899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.831981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.933554, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.035044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.136541, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.238468, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.340173, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.442019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.54362, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.617582, "o", "\u001b[4;6H\u001b[1m\u001b[38;5;3;49m Customer\u001b[4m Date\u001b[6;6H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Stark Enterprises\u001b[1m\u001b[38;5;0;48;5;6m 2025-02-20\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.719176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.819708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.901714, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Date\u001b[4m Product\u001b[6;24H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02-20\u001b[1m\u001b[38;5;0;48;5;6m Sprockets\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.003506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.106764, "o", "\u001b[4;35H\u001b[1m\u001b[38;5;3;49m Product\u001b[4m Region\u001b[6;35H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m Sprockets\u001b[1m\u001b[38;5;0;48;5;6m East\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.208137, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.309835, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.344493, "o", "\u001b[4;45H\u001b[1m\u001b[38;5;3;49m Region\u001b[4m Date_Month\u001b[6;45H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m East\u001b[1m\u001b[38;5;0;48;5;6m 2025-02\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.445881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.547204, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.582215, "o", "\u001b[4;52H\u001b[1m\u001b[38;5;3;49m Date_Month\u001b[4m Value\u001b[6;52H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 2025-02\u001b[1m\u001b[38;5;0;48;5;6m 4200\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.683651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.785171, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.88631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.987214, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.088752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.19032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.2922, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.393169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.494592, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.551364, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4200 \u001b[37;1H\u001b[24m\u001b[38;5;2;48;5;235medit: 4200▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.653106, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.75426, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.855143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.956612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.057339, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.158822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.260463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.362035, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.463667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.565019, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.658576, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.75945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.861196, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.962228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.06407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.088362, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.189174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.290284, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.392056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.49337, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.595101, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.659247, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.760985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.862189, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.96398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.977937, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.079343, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.146926, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.248174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.315613, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.417369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.518587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.620531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.639076, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.740677, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.841462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.9429, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.044326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.145842, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.247478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.347982, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.449704, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.551334, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.653012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.754874, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.855501, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.957138, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.057576, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.159355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.26075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.361942, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.4634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.564497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.666245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.7679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.868507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.969901, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.071612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.172648, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.200869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.302398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.403782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.505197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.606417, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.708289, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.809292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.910682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.011963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.113483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.214542, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.31553, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.377483, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m4\u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m4▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.479033, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.580766, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.63521, "o", "\u001b[6;64H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m5\u001b[37;8H\u001b[24m\u001b[38;5;2;48;5;235m5▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.736712, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.80774, "o", "\u001b[6;65H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;9H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.909121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.000933, "o", "\u001b[6;66H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m0\u001b[37;10H\u001b[24m\u001b[38;5;2;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.10274, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.203875, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.305319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.406083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.471834, "o", "\u001b[6;63H\u001b[1m\u001b[4m\u001b[38;5;2;48;5;6m \u001b[37;7H\u001b[24m\u001b[38;5;2;48;5;235m▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.57245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.673931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.775416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.876873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.978402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.079364, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.181199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.28257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.323021, "o", "\u001b[6;63H\u001b[1m\u001b[38;5;0;48;5;6m 4500\u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;8m NORMAL Added new record row _Drill \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.425018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.526725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.62833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.729186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.830402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.931931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.033936, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.135577, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.236664, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.33801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.363396, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises]\u001b[3;34H\u001b[39;49m \u001b[4;6H \u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[24m Profit\u001b[22m\u001b[39;49m \u001b[6;2HGadgets East \u001b[3m 10,850 15,600 4,750\u001b[23m \u001b[7;2HWidgets\u001b[7;12HEast\u001b[7;17H\u001b[3m 14,580 24,300 9,720\u001b[8;2H\u001b[23m\u001b[1m\u001b[38;5;6;48;5;237mSprockets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 2,940\u001b[1m\u001b[38;5;0;48;5;6m 4,500\u001b[22m\u001b[38;5;15;48;5;237m 1,560\u001b[23m\u001b[39;48;5;237m \u001b[9;2H\u001b[38;5;8;49m───────────────────────────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[59.363433, "o", "───────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,400 16,030\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.46552, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.567009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.668443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.770756, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.872967, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.974045, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.075696, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.177744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.279764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.380471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.482824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.584652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.686336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.787808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.888707, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.990743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.092529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.193873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.295612, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.397566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.498529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.600461, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.70169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.803645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.905272, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.007052, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.108297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.209571, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.311535, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.413278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.514977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.616556, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.718025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.819687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.920927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.022617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.039683, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.140302, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.242332, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.344261, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.352965, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.454163, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.55584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.656611, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.758111, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.859338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.955664, "o", "\u001b[?1049l"]
|
||||||
|
[63.955716, "o", "\u001b[?25h"]
|
||||||
|
[63.957068, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[63.999517, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[64.14143, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:06:33\r\n16026:% \u001b[K"]
|
||||||
|
[64.141514, "o", "\u001b[?2004h"]
|
||||||
|
[64.491198, "o", "e"]
|
||||||
|
[64.739691, "o", "\bex"]
|
||||||
|
[64.9105, "o", "i"]
|
||||||
|
[65.087513, "o", "t"]
|
||||||
|
[65.267946, "o", "\u001b[?2004l\r\r\n"]
|
||||||
|
[65.269911, "o", "\u001b]0;exit\u0007\u001b[2 q"]
|
||||||
825
docs/casts/formulas.cast
Normal file
825
docs/casts/formulas.cast
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
{"version": 2, "width": 120, "height": 37, "timestamp": 1775772426, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}}
|
||||||
|
[0.22204, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[0.229576, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[0.348862, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:07:06\r\n16026:% \u001b[K"]
|
||||||
|
[0.348973, "o", "\u001b[?2004h"]
|
||||||
|
[4.839346, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"]
|
||||||
|
[5.712315, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l\r\r\n"]
|
||||||
|
[5.713215, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"]
|
||||||
|
[5.754065, "o", "\u001b[?1049h"]
|
||||||
|
[5.756407, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"]
|
||||||
|
[5.756495, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[5.756562, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"]
|
||||||
|
[5.756628, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"]
|
||||||
|
[5.756698, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.859411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.963254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.067489, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.170023, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.27251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.375969, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.479677, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.582112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.685348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.787542, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.866052, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[6.866248, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "]
|
||||||
|
[6.866345, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.967808, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.07036, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.171986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.273477, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.37608, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.477589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.579865, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.681762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.784444, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.887075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.989043, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.091087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.192621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.295349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.396347, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.499325, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.601635, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.703762, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.805045, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.907044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.008857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.110585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.212366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.315082, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.417176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.518617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.620581, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.723247, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.825828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.927309, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.029287, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.131114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.232595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.334463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.436432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.537342, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.586615, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.688844, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.79055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.891459, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.992994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.094817, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.196506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.208979, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.310676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.412609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.472655, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.57519, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.621489, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.723727, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.810324, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.912742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.992715, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.095391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.197498, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.225504, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.327415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.429585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.534313, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.636065, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.738586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.840575, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.942807, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.045034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.147374, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.248519, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.350293, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.411399, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;20H\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 15,500 4,650\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 14,580 24,300 9,720\u001b[23m \u001b[8;2HSprockets\u001b[8;19H\u001b[3m2,940 4,200 \u001b[8;34H1,260\u001b[23m \u001b[9;2H\u001b[38;5;8;49m────────────"]
|
||||||
|
[13.411561, "o", "──────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[22m\u001b[39;49m \u001b[11;2H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HN\u001b[37;25Hne \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.513136, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.61531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.717724, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.819203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.920313, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.021863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.122842, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.224088, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.325723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.426976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.527862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.629536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.731181, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.777459, "o", "\u001b[36;103H\u001b[38;5;8;49m [Date_Month ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.879201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.980529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.081904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.182728, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.284144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.386367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.488405, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.590322, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.691881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.771463, "o", "\u001b[2;88H┐\u001b[38;5;3;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[1m\u001b[38;5;0;48;5;3m Profit = Revenue - Cost\u001b[3;120H\u001b[22m\u001b[38;5;3;49m│\u001b[4;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[4;120H│\u001b[5;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[6;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[7;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[7;120H│\u001b[8;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[8;120H│\u001b[9;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[39;49m \u001b[38;5;3;49m│\u001b[10;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[10;120H│\u001b[11;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[11;120H│\u001b[12;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[12;120H│\u001b[13;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[13;120H│\u001b[14;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[14;120H│\u001b[15;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[15;120H│\u001b[16;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[16;120H│\u001b[17;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[17;120H│\u001b[18;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[18;120H│\u001b[19;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[19;120H│"]
|
||||||
|
[15.771523, "o", "\u001b[20;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[20;120H│\u001b[21;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[21;120H│\u001b[22;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[22;120H│\u001b[23;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[23;120H│\u001b[24;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[24;120H│\u001b[25;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[25;120H│\u001b[26;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[26;120H│\u001b[27;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[27;120H│\u001b[28;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[28;120H│\u001b[29;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[29;120H│\u001b[30;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[30;120H│\u001b[31;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[31;120H│\u001b[32;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[32;120H│\u001b[33;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[33;120H│\u001b[34;88H\u001b[39;49m│\u001b[38;5;3;49m│\u001b[34;120H│\u001b[35;88H\u001b[39;49m┘\u001b[38;5;3;49m└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mF\u001b[37;7HU\u001b[37;9HAS Date_Month\u001b[37;24H→ None\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.873015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.974298, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.075518, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.17702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.278763, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.380421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.482397, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.584035, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.68617, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.788334, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.890119, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.992122, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.09391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.195828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.297212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.399072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.500797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.602599, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.704051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.806336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.90867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.01054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.112401, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.134611, "o", "\u001b[34;90H\u001b[38;5;3;49m┄ Enter formula (Name = expr)\u001b[37;1H\u001b[1m\u001b[38;5;6;48;5;235mformula: ▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.236595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.338804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.440583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.541464, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.642436, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.656428, "o", "\u001b[37;10H\u001b[1m\u001b[38;5;6;48;5;235mM▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.757466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.858857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.957266, "o", "\u001b[37;11H\u001b[1m\u001b[38;5;6;48;5;235ma▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.058764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.104126, "o", "\u001b[37;12H\u001b[1m\u001b[38;5;6;48;5;235mr▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.206143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.254003, "o", "\u001b[37;13H\u001b[1m\u001b[38;5;6;48;5;235mg▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.355945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.40299, "o", "\u001b[37;14H\u001b[1m\u001b[38;5;6;48;5;235mi▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.505037, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.550273, "o", "\u001b[37;15H\u001b[1m\u001b[38;5;6;48;5;235mn▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.651795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.731805, "o", "\u001b[37;16H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.833103, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.88046, "o", "\u001b[37;17H\u001b[1m\u001b[38;5;6;48;5;235m=▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.982487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.0286, "o", "\u001b[37;18H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.129755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.180513, "o", "\u001b[37;19H\u001b[1m\u001b[38;5;6;48;5;235m1▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.282654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.334596, "o", "\u001b[37;20H\u001b[1m\u001b[38;5;6;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.436242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.484785, "o", "\u001b[37;21H\u001b[1m\u001b[38;5;6;48;5;235m0▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.58654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.664649, "o", "\u001b[37;22H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.765442, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.867084, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.96863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.991979, "o", "\u001b[37;23H\u001b[1m\u001b[38;5;6;48;5;235m*▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.09353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.161257, "o", "\u001b[37;24H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.263347, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.364397, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.465102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.566609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.636594, "o", "\u001b[37;25H\u001b[1m\u001b[38;5;6;48;5;235mP▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.73766, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.839314, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.930228, "o", "\u001b[37;26H\u001b[1m\u001b[38;5;6;48;5;235mr▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.030863, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.080686, "o", "\u001b[37;27H\u001b[1m\u001b[38;5;6;48;5;235mo▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.1822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.232387, "o", "\u001b[37;28H\u001b[1m\u001b[38;5;6;48;5;235mf▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.334144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.432432, "o", "\u001b[37;29H\u001b[1m\u001b[38;5;6;48;5;235mi▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.534534, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.602656, "o", "\u001b[37;30H\u001b[1m\u001b[38;5;6;48;5;235mt▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.704246, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.749303, "o", "\u001b[37;31H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.850361, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.900622, "o", "\u001b[37;32H\u001b[1m\u001b[38;5;6;48;5;235m/▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.001894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.071145, "o", "\u001b[37;33H\u001b[1m\u001b[38;5;6;48;5;235m ▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.172849, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.273367, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.374896, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.379148, "o", "\u001b[37;34H\u001b[1m\u001b[38;5;6;48;5;235mR▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.480866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.582066, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.684013, "o", "\u001b[37;35H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.784848, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.834019, "o", "\u001b[37;36H\u001b[1m\u001b[38;5;6;48;5;235mv▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.935533, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.000423, "o", "\u001b[37;37H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.101663, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.187318, "o", "\u001b[37;38H\u001b[1m\u001b[38;5;6;48;5;235mn▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.288471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.374963, "o", "\u001b[37;39H\u001b[1m\u001b[38;5;6;48;5;235mu▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.47588, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.578051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.679745, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.781518, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.883965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.985622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.087356, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.189121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.290385, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.392349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.493825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.595221, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.696678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.798409, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.900396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.001989, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.103513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.205321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.280519, "o", "\u001b[37;40H\u001b[1m\u001b[38;5;6;48;5;235me▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.382821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.48443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.586182, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.68818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.790294, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.892088, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.958354, "o", "\u001b[4;39H\u001b[1m\u001b[38;5;3;49m Margin\u001b[4;90H\u001b[22m\u001b[38;5;2;49m Margin = 100 * Profit / Rev…\u001b[6;39H\u001b[3m\u001b[38;5;15;48;5;237m 30\u001b[7;39H\u001b[39;49m 40\u001b[8;39H 30\u001b[10;39H\u001b[23m\u001b[1m\u001b[38;5;3;49m 100\u001b[34;90H\u001b[22m\u001b[39;49m \u001b[37;1H\u001b[38;5;0;48;5;8m FORMULAS Formula added Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.060639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.162564, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.264269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.36574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.467341, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.568744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.670688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.7723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.873949, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.975642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.077335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.178827, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.280529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.382127, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.48383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.586228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.687929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.790028, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.823665, "o", "\u001b[2;89H\u001b[38;5;8;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;89H│\u001b[38;5;2;49m Profit = Revenue - Cost\u001b[3;120H\u001b[38;5;8;49m│\u001b[4;89H│\u001b[4;120H│\u001b[5;89H│\u001b[5;120H│\u001b[6;89H│\u001b[6;120H│\u001b[7;89H│\u001b[7;120H│\u001b[8;89H│\u001b[8;120H│\u001b[9;89H│\u001b[9;120H│\u001b[10;89H│\u001b[10;120H│\u001b[11;89H│\u001b[11;120H│\u001b[12;89H│\u001b[12;120H│\u001b[13;89H│\u001b[13;120H│\u001b[14;89H│\u001b[14;120H│\u001b[15;89H│\u001b[15;120H│\u001b[16;89H│\u001b[16;120H│\u001b[17;89H│\u001b[17;120H│\u001b[18;89H│\u001b[18;120H│\u001b[19;89H│\u001b[19;120H│\u001b[20;89H│\u001b[20;120H│\u001b[21;89H│\u001b[21;120H│\u001b[22;89H│\u001b[22;120H│\u001b[23;89H│\u001b[23;120H│\u001b[24;89H│\u001b[24;120H│\u001b[25;89H│\u001b[25;120H│\u001b[26;89H│\u001b[26;120H│\u001b[27;89H│\u001b[27;120H│\u001b[28;89H│\u001b[28;120H│\u001b[29;89H│\u001b[29;120H│\u001b[30;89H│\u001b[30;120H│\u001b[31;89H│\u001b[31;120H│\u001b[32;89H│\u001b[32;120H│\u001b[33;89H│\u001b[33;120H│\u001b[34;89H│\u001b[34;120H│\u001b[35;89H└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mN\u001b[37;7HA\u001b[37;9H Formula \u001b[37;20Hdde\u001b[37;24H \u001b[39m\u001b[49m\u001b[59"]
|
||||||
|
[28.82381, "o", "m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.925823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.028152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.129687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.23137, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.333203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.434603, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.536387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.638191, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.74009, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.841736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.943467, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.045355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.146794, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.248304, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.350033, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.452178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.553839, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.655545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.757685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.859652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.961651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.063178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.164853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.26659, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.368454, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.469947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.571386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.673047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.774632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.83504, "o", "\u001b[2;89H\u001b[38;5;3;49m┌ Formulas [n]ew [d]elete ─────┐\u001b[3;89H│\u001b[1m\u001b[38;5;0;48;5;3m Profit = Revenue - Cost\u001b[3;120H\u001b[22m\u001b[38;5;3;49m│\u001b[4;89H│\u001b[4;120H│\u001b[5;89H│\u001b[5;120H│\u001b[6;89H│\u001b[6;120H│\u001b[7;89H│\u001b[7;120H│\u001b[8;89H│\u001b[8;120H│\u001b[9;89H│\u001b[9;120H│\u001b[10;89H│\u001b[10;120H│\u001b[11;89H│\u001b[11;120H│\u001b[12;89H│\u001b[12;120H│\u001b[13;89H│\u001b[13;120H│\u001b[14;89H│\u001b[14;120H│\u001b[15;89H│\u001b[15;120H│\u001b[16;89H│\u001b[16;120H│\u001b[17;89H│\u001b[17;120H│\u001b[18;89H│\u001b[18;120H│\u001b[19;89H│\u001b[19;120H│\u001b[20;89H│\u001b[20;120H│\u001b[21;89H│\u001b[21;120H│\u001b[22;89H│\u001b[22;120H│\u001b[23;89H│\u001b[23;120H│\u001b[24;89H│\u001b[24;120H│\u001b[25;89H│\u001b[25;120H│\u001b[26;89H│\u001b[26;120H│\u001b[27;89H│\u001b[27;120H│\u001b[28;89H│\u001b[28;120H│\u001b[29;89H│\u001b[29;120H│\u001b[30;89H│\u001b[30;120H│\u001b[31;89H│\u001b[31;120H│\u001b[32;89H│\u001b[32;120H│\u001b[33;89H│\u001b[33;120H│\u001b[34;89H│\u001b[34;120H│\u001b[35;89H└──────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mF\u001b[37;7HU\u001b[37;9HAS Formul\u001b[37;20H ad\u001b[37;24Hed\u001b"]
|
||||||
|
[31.83521, "o", "[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.93635, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.038453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.140305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.24179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.343021, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.444953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.546884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.648532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.750338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.759627, "o", "\u001b[2;88H────────────────────────────────┐\u001b[3;88H \u001b[3;120H│\u001b[4;88H │\u001b[5;88H\u001b[38;5;8;49m────────────────────────────────\u001b[39;49m│\u001b[6;88H\u001b[39;48;5;237m \u001b[39;49m│\u001b[7;88H \u001b[7;120H│\u001b[8;88H \u001b[8;120H│\u001b[9;88H\u001b[38;5;8;49m────────────────────────────────\u001b[39;49m│\u001b[10;88H \u001b[10;120H│\u001b[11;88H \u001b[11;120H│\u001b[12;88H \u001b[12;120H│\u001b[13;88H \u001b[13;120H│\u001b[14;88H \u001b[14;120H│\u001b[15;88H \u001b[15;120H│\u001b[16;88H \u001b[16;120H│\u001b[17;88H \u001b[17;120H│\u001b[18;88H \u001b[18;120H│\u001b[19;88H \u001b[19;120H│\u001b[20;88H \u001b[20;120H│\u001b[21;88H \u001b[21;120H│\u001b[22;88H \u001b[22;120H│\u001b[23;88H \u001b[23;120H│\u001b[24;88H \u001b[24;120H│\u001b[25;88H \u001b[25;120H│\u001b[26;88H \u001b[26;120H│\u001b[27;88H \u001b[27;120H│\u001b[28;88H \u001b[28;120H│\u001b[29;88H \u001b[29;120H│\u001b[30;88H \u001b[30;120H│\u001b[31;88H \u001b[3"]
|
||||||
|
[32.759661, "o", "1;120H│\u001b[32;88H \u001b[32;120H│\u001b[33;88H \u001b[33;120H│\u001b[34;88H \u001b[34;120H│\u001b[35;88H────────────────────────────────┘\u001b[37;3H\u001b[38;5;0;48;5;8mN\u001b[37;7HA\u001b[37;9H Formula \u001b[37;20Hdde\u001b[37;24H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.861687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.963943, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.065622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.167382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.269343, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.370929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.472205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.574072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.675496, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.77751, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.878963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.981217, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.082469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.18416, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.285853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.387215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.488822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.590148, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.691868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.793678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.894883, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.996624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.098854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.199728, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.30097, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.402643, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.504418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.605968, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.707659, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.813626, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.915915, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.017878, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.119781, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.221525, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.32263, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.424295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.525566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.627568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.729398, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.830794, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.9326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.956636, "o", "\u001b[4;17H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;17H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m 15,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.057713, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.159175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.260616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.362003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.45116, "o", "\u001b[4;24H\u001b[1m\u001b[38;5;3;49m Revenue\u001b[4m Profit\u001b[6;24H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 15,500\u001b[1m\u001b[38;5;0;48;5;6m 4,650\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.553269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.654984, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.756544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.855164, "o", "\u001b[4;32H\u001b[1m\u001b[38;5;3;49m Profit\u001b[4m Margin\u001b[6;32H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 4,650\u001b[1m\u001b[38;5;0;48;5;6m 30\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.95702, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.05867, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.16023, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.261882, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.364382, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.465665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.568143, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.66929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.770404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.872114, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.9737, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.075442, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.177113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.279174, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.381483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.483532, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.585266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.68695, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.788157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.890074, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.991552, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.092834, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.194587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.283998, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Formula added Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.386092, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.487642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.5894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.691098, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.792446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.894254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.995483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.048638, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.150203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.252282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.354288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.363494, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.465135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.567014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.652538, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.753272, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.854376, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.886425, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.988321, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.089981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.191526, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.210178, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.312329, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.414458, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.516377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.523473, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.624892, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.726668, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.828869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.929605, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.969045, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.071061, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.172731, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.274463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.375888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.47684, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.578927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.680089, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.759548, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;8;49m [Date_Month ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.86162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.963419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.064814, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.165578, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.266915, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.368077, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.467449, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.569373, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.6706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.772271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.873952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.975198, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.067862, "o", "\u001b[4;17H \u001b[1m\u001b[38;5;3;49m Cost Revenue Profit\u001b[4m Margin\u001b[6;2H\u001b[24m\u001b[38;5;6;48;5;237m2025-03-25 Gadgets East \u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 5,670 8,100 2,430\u001b[1m\u001b[38;5;0;48;5;6m 30\u001b[7;2H\u001b[22m\u001b[23m\u001b[39;49m2025-03-05 Widgets East \u001b[3m 7,500 12,500\u001b[7;44H 5,000 40\u001b[8;2H\u001b[23m2025-01-31 Gadgets East \u001b[3m 5,180 7,400\u001b[8;44H 2,220 30\u001b[9;2H\u001b[23m2025-02-20 Sprockets East \u001b[3m 2,940 4,200 1,260 30\u001b[23m \u001b[10;2H2025-01-17 Widgets East \u001b[3m 7,080 11,800 4,720 40\u001b[11;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630 170"]
|
||||||
|
[45.067912, "o", "\u001b[36;71H\u001b[38;5;0;48;5;6mRow] \u001b[22m\u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;10H\u001b[38;5;0;48;5;5mDate → Row \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.169513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.271027, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.372769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.47433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.575726, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.677188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.779086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.880875, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.982323, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.084014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.185947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.287438, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.388634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.490413, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.59213, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.693451, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.795674, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.897368, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.999478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.100406, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.202354, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.303499, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.405377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.507349, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.608859, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.710466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.769032, "o", "\u001b[36;64H\u001b[38;5;2;49m [Date Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date → Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.870773, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.973227, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.074952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.176627, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.278078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.379913, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.48167, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.583195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.68488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.786081, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.888008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.989799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.090852, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.192203, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.293577, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.395558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.497376, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.598701, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.700034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.800738, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.902188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.004731, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.10652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.208391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.310331, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.412538, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.514326, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.616773, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.717698, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.820295, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.921874, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.023865, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.125694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.227335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.328825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.430927, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.479132, "o", "\u001b[4;43H\u001b[1m\u001b[4m\u001b[38;5;3;49m Profit\u001b[24m Margin\u001b[6;43H\u001b[3m\u001b[38;5;0;48;5;6m 2,430\u001b[22m\u001b[38;5;15;48;5;237m 30\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.581056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.682894, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.784319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.886355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.988029, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.09004, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.191833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.294121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.396186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.447223, "o", "\u001b[4;35H\u001b[1m\u001b[4m\u001b[38;5;3;49m Revenue\u001b[24m Profit\u001b[6;35H\u001b[3m\u001b[38;5;0;48;5;6m 8,100\u001b[22m\u001b[38;5;15;48;5;237m 2,430\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.548933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.650639, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.753129, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.855032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.956843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.992371, "o", "\u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue\u001b[6;28H\u001b[3m\u001b[38;5;0;48;5;6m 5,670\u001b[22m\u001b[38;5;15;48;5;237m 8,100\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.093934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.195476, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.297821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.39909, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.5012, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.603706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.705669, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.807421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[53.909072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.010788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.113124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.214937, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.315779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.401838, "o", "\u001b[2;9H_Dril\u001b[2;15H ─\u001b[3;4H\u001b[38;5;5;49m_Measure\u001b[3;15HCost | Customer = Stark Enterprises | Date = 2025-03-25 | Product = Gadgets | Region = East] \u001b[4;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Customer\u001b[24m Date\u001b[4;36HProduct\u001b[4;44HRegion\u001b[4;51HD\u001b[4;53Hte_Month Value\u001b[6;2H\u001b[38;5;6;48;5;237m0 \u001b[38;5;0;48;5;6m Stark Enterprises\u001b[22m\u001b[38;5;15;48;5;237m 2025-03-25 Gadgets East 2025-03 5670\u001b[7;2H\u001b[39;49m \u001b[7;13H \u001b[7;23H \u001b[7;28H \u001b[8;2H \u001b[8;13H \u001b[8;23H \u001b[8;28H \u001b[9;2H \u001b[9;13H \u001b[9;23H \u001b[9;28H \u001b[10;2H \u001b[10;13H \u001b[10;23H \u001b[10;28H \u001b[11;2H \u001b[12;2H \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;5;49m [_Measure Pag] [Customer Pag] [Date Pag] [Produc"]
|
||||||
|
[54.402009, "o", "t Pag] [Region Pag] \u001b[38;5;8;49m▶\u001b[39;49m \u001b[37;12H\u001b[38;5;0;48;5;8mrilled into\u001b[37;24Hcell:\u001b[37;30H1\u001b[37;32Hrows\u001b[37;110H _Drill\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.503698, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.605285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.706675, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.807631, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[54.909386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.011169, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.113111, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.214654, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.316369, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.417789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.51931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.620988, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.722632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.823944, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[55.925315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.027107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.128956, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.230678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.332344, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.433936, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.535729, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.637293, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.738954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.840527, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[56.942072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.043456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.150712, "o", "\u001b[2;9HDefau\u001b[2;15Ht \u001b[3;4H\u001b[38;5;5;49mCustomer\u001b[3;15HStark Enterprises] \u001b[39;49m \u001b[4;6H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[4;36H\u001b[24mRevenue\u001b[4;44HProfit\u001b[4;51HM\u001b[4;53Hrgin\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237m2025-03-25 Gadgets East \u001b[3m\u001b[38;5;0;48;5;6m 5,670\u001b[22m\u001b[38;5;15;48;5;237m 8,100 2,430 30\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m2025-03-05\u001b[7;13HWidgets\u001b[7;23HEast\u001b[7;28H\u001b[3m 7,500 12,500 5,000 40\u001b[8;2H\u001b[23m2025-01-31\u001b[8;13HGadgets\u001b[8;23HEast\u001b[8;28H\u001b[3m 5,180 7,400 2,220 30\u001b[9;2H\u001b[23m2025-02-20\u001b[9;13HSprockets\u001b[9;23HEast\u001b[9;28H\u001b[3m 2,940 4,200 1,260 30\u001b[10;2H\u001b[23m2025-01-17\u001b[10;13HWidgets\u001b[10;23HEast\u001b[10;28H\u001b[3m 7,080 11,800 4,720 40\u001b[11;2H\u001b[23m\u001b[38;5;8;49m─────────────────────────────────────────────────────────────────────"]
|
||||||
|
[57.150756, "o", "─────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630 170\u001b[36;10H\u001b[22m\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;2;49m [Date Row] [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[37;112H\u001b[38;5;0;48;5;8mDefau\u001b[37;118Ht\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.252651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.354764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.455662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.557383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.658952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.760706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.862365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[57.964025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.066018, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.117152, "o", "\u001b[3;16H\u001b[38;5;5;49moylent Ltd] \u001b[39;49m \u001b[4;28H \u001b[4;31H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit Margin\u001b[6;8H\u001b[38;5;6;48;5;237m2\u001b[6;11H2\u001b[6;13HSprockets\u001b[6;23HSou\u001b[6;27Hh \u001b[6;30H\u001b[3m\u001b[38;5;0;48;5;6m 2,170\u001b[6;38H\u001b[22m\u001b[38;5;15;48;5;237m 3,1\u001b[6;43H0\u001b[6;45H 930\u001b[6;55H 30\u001b[7;10H\u001b[23m\u001b[39;49m18\u001b[7;13HGa\u001b[7;23HSou\u001b[7;27Hh \u001b[7;30H\u001b[3m 4,760\u001b[7;37H 6,8\u001b[7;43H0\u001b[7;45H 2,\u001b[7;49H40\u001b[7;55H 30\u001b[8;10H\u001b[23m14\u001b[8;23HSou\u001b[8;27Hh \u001b[8;30H\u001b[3m 3,\u001b[8;34H50\u001b[8;38H 5,5\u001b[8;43H0\u001b[8;45H 1,650\u001b[8;55H 30\u001b[9;10H\u001b[23m03\u001b[9;13HWidgets \u001b[9;23HSou\u001b[9;27Hh \u001b[9;30H\u001b[3m 6,120\u001b[9;38H10,2\u001b[9;43H0\u001b[9;45H 4,080\u001b[9;55H 40\u001b[10;10H\u001b[23m09\u001b[10;23HSou\u001b[10;27Hh \u001b[10;30H\u001b[3m 5,\u001b[10;34H80\u001b[10;37H 9,8\u001b[10;43H0\u001b[10;45H 3,920\u001b[10;55H 40\u001b[12;29H\u001b[23m\u001b[1m\u001b[38;5;3;49m 22,\u001b[12;34H80\u001b[12;37H 35,4\u001b[12;43H0 12,620\u001b[12;54H 170\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.218907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.320446, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.422194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.523868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.625832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.727316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.828823, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.871774, "o", "\u001b[3;15H\u001b[38;5;5;49mW\u001b[3;17Hnka Industries] \u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;31HCost\u001b[24m Revenue Profit Margin\u001b[22m\u001b[39;49m \u001b[6;8H\u001b[1m\u001b[38;5;6;48;5;237m1\u001b[6;11H3\u001b[6;13HGadgets \u001b[6;23HEas\u001b[6;27H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[6;30H6,230\u001b[22m\u001b[38;5;15;48;5;237m \u001b[6;38H8,90\u001b[6;43H \u001b[6;45H2,670 \u001b[6;55H30\u001b[23m\u001b[39;48;5;237m \u001b[7;8H\u001b[39;49m1\u001b[7;10H06\u001b[7;13HWi\u001b[7;23HEas\u001b[7;27H \u001b[3m \u001b[7;30H8,520 \u001b[7;37H14,20\u001b[7;43H \u001b[7;45H5,680 \u001b[7;55H40\u001b[23m \u001b[8;8H2\u001b[8;11H0\u001b[8;13HWi\u001b[8;23HEas\u001b[8;27H \u001b[3m \u001b[8;30H9,000 \u001b[8;37H15,00\u001b[8;43H \u001b[8;45H6,000 \u001b[8;55H40\u001b[23m \u001b[9;8H3\u001b[9;10H14\u001b[9;13HSprockets\u001b[9;23HEas\u001b[9;27H \u001b[3m \u001b[9;30H3,360 \u001b[9;38H4,80\u001b[9;43H \u001b[9;45H1,440 \u001b[9;55H30\u001b[23m \u001b[10;8H2\u001b[10;10H28\u001b[10;13HGa\u001b[10;23HEas\u001b[10;27H \u001b[3m \u001b[10;30H6,440 \u001b[10;38H9,20\u001b[10;43H \u001b[10;45H2,760 \u001b[10;55H30\u001b[23m \u001b[12;29H\u001b[1m\u001b[38;5;3;49m33,550 \u001b[12;37H52,10\u001b[12;43H 18,550 \u001b[12;54H170\u001b[22m\u001b[39;49m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[58.973652, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.07548, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.177244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.278352, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.380072, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.481511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.596012, "o", "\u001b[3;15H\u001b[38;5;5;49mCyberdy\u001b[3;23He Syst\u001b[3;30Hms] \u001b[6;8H\u001b[1m\u001b[38;5;6;48;5;237m3\u001b[6;10H12\u001b[6;23HWe\u001b[6;30H\u001b[3m\u001b[38;5;0;48;5;6m5\u001b[6;33H5\u001b[6;38H\u001b[22m\u001b[38;5;15;48;5;237m7\u001b[6;40H5\u001b[6;47H25\u001b[7;8H\u001b[23m\u001b[39;49m3\u001b[7;10H30\u001b[7;23HWe\u001b[7;30H\u001b[3m5\u001b[7;32H88\u001b[7;37H 9\u001b[7;40H8\u001b[7;45H3\u001b[7;47H92\u001b[8;8H\u001b[23m1\u001b[8;11H9\u001b[8;13HGa\u001b[8;23HWe\u001b[8;30H\u001b[3m4\u001b[8;32H69\u001b[8;37H 6\u001b[8;40H7\u001b[8;45H2\u001b[8;48H1\u001b[8;55H3\u001b[9;8H\u001b[23m2\u001b[9;10H2\u001b[9;23HWe\u001b[9;30H\u001b[3m2\u001b[9;32H52\u001b[9;38H3\u001b[9;40H6\u001b[9;47H08\u001b[10;10H\u001b[23m06\u001b[10;13HWi\u001b[10;23HWe\u001b[10;32H\u001b[3m72\u001b[10;37H11\u001b[10;45H4\u001b[10;47H48\u001b[10;55H4\u001b[12;29H\u001b[23m\u001b[1m\u001b[38;5;3;49m25\u001b[12;32H06\u001b[12;37H38\u001b[12;40H8\u001b[12;45H3\u001b[12;47H74\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.697594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.798754, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[59.901337, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.002239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.104595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.206801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.308609, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.324926, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.42746, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.529292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.631466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.733806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.835985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[60.937904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.039574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.141223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.158547, "o", "\u001b[4;28H\u001b[1m\u001b[38;5;3;49m Cost\u001b[4m Revenue\u001b[6;28H\u001b[22m\u001b[24m\u001b[3m\u001b[38;5;15;48;5;237m 5,250\u001b[1m\u001b[38;5;0;48;5;6m 7,500\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.260014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.361614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.463241, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.565458, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.66735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.769039, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.870889, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[61.972547, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.073955, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.174929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.276844, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.378546, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.480073, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.581655, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.683563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.710581, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Drilled into cell: 1 rows Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.812801, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[62.914448, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.016597, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.118395, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.141392, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.243176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.344884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.447866, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.548899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.650249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.717023, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.818713, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[63.919536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.02132, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.122911, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.184957, "o", "\u001b[3;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;28H\u001b[22m\u001b[24m\u001b[39;49m \u001b[4;30H\u001b[1m\u001b[38;5;3;49mStark Enterprises\u001b[4m Soylent Ltd\u001b[24m Wonka Industries Cyberdyne Systems Acme Corp\u001b[6;8H\u001b[38;5;6;48;5;237m1\u001b[6;10H23\u001b[6;23HEa\u001b[6;28H \u001b[22m\u001b[3m\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;15;48;5;237m 6,230\u001b[38;5;8;48;5;237m \u001b[7;10H\u001b[23m\u001b[39;49m25\u001b[7;13HGa\u001b[7;23HEa\u001b[7;28H \u001b[7;30H\u001b[3m \u001b[7;38H 5,670\u001b[38;5;8;49m \u001b[8;11H\u001b[23m\u001b[39;49m1\u001b[8;13HWi\u001b[8;28H \u001b[3m\u001b[38;5;8;49m \u001b[9;8H\u001b[23m\u001b[39;49m3\u001b[9;10H12\u001b[9;13HGadgets \u001b[9;28H \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,250\u001b[38;5;8;49m \u001b[10;8H\u001b[23m\u001b[39;49m1\u001b[10;23HEa\u001b[10;28H \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8"]
|
||||||
|
[64.185061, "o", ",520\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m2025-01-15 Widgets North \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 7,200\u001b[23m \u001b[12;2H2025-03-30 Widgets West \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[13;2H\u001b[23m\u001b[39;49m2025-03-10\u001b[13;13HWidgets\u001b[13;23HNorth\u001b[13;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,100\u001b[14;2H\u001b[23m2025-03-05\u001b[14;13HWidgets\u001b[14;23HEast\u001b[14;29H\u001b[3m 7,500\u001b[38;5;8;49m \u001b[15;2H\u001b[23m\u001b[39;49m2025-01-19\u001b[15;13HGadgets\u001b[15;23HWest\u001b[15;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,690\u001b[38;5;8;49m \u001b[16;2H\u001b[23m\u001b[39;49m2025-03-28\u001b[16;13HSprockets\u001b[16;23HSouth\u001b[16;29H\u001b[3m\u001b[38;5;8;49m \u001b[17;2H\u001b[23m\u001b[39;49m"]
|
||||||
|
[64.185124, "o", "2025-01-22\u001b[17;13HWidgets\u001b[17;23HNorth\u001b[17;29H\u001b[3m\u001b[38;5;8;49m \u001b[18;2H\u001b[23m\u001b[39;49m2025-01-30\u001b[18;13HGadgets\u001b[18;23HNorth\u001b[18;29H\u001b[3m\u001b[38;5;8;49m \u001b[19;2H\u001b[23m\u001b[39;49m2025-01-12\u001b[19;13HSprockets\u001b[19;23HNorth\u001b[19;29H\u001b[3m\u001b[38;5;8;49m \u001b[20;2H\u001b[23m\u001b[39;49m2025-02-15\u001b[20;13HGadgets\u001b[20;23HSouth\u001b[20;29H\u001b[3m\u001b[38;5;8;49m \u001b[21;2H\u001b[23m\u001b[39;49m2025-03-07\u001b[21;13HWidgets\u001b[21;23HSouth\u001b[21;29H\u001b[3m\u001b[38;5;8;49m \u001b[22;2H\u001b[23m\u001b[39;49m2025-01-31\u001b[22;13HGadgets\u001b[22;23HEast\u001b[22;29H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[23;2H\u001b[23m\u001b[39;49m2025-01-27\u001b[23;13HSprockets\u001b[23;23HWest\u001b[23;29H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[64.185176, "o", " \u001b[24;2H\u001b[23m\u001b[39;49m2025-01-28\u001b[24;13HSprockets\u001b[24;23HSouth\u001b[24;29H\u001b[3m\u001b[38;5;8;49m \u001b[25;2H\u001b[23m\u001b[39;49m2025-03-03\u001b[25;13HWidgets\u001b[25;23HWest\u001b[25;29H\u001b[3m\u001b[38;5;8;49m \u001b[26;2H\u001b[23m\u001b[39;49m2025-03-19\u001b[26;13HGadgets\u001b[26;23HNorth\u001b[26;29H\u001b[3m\u001b[38;5;8;49m \u001b[27;2H\u001b[23m\u001b[39;49m2025-02-05\u001b[27;13HWidgets\u001b[27;23HNorth\u001b[27;29H\u001b[3m\u001b[38;5;8;49m \u001b[28;2H\u001b[23m\u001b[39;49m2025-02-22\u001b[28;13HSprockets\u001b[28;23HSouth\u001b[28;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,170\u001b[38;5;8;49m \u001b[29;2H\u001b[23m\u001b[39;49m2025-02-25\u001b[29;13HSprockets\u001b[29;23HNorth\u001b[29;29H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[64.185201, "o", "\u001b[30;2H\u001b[23m\u001b[39;49m2025-03-18\u001b[30;13HGadgets\u001b[30;23HSouth\u001b[30;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,760\u001b[38;5;8;49m \u001b[31;2H\u001b[23m\u001b[39;49m2025-02-20\u001b[31;13HSprockets\u001b[31;23HEast\u001b[31;29H\u001b[3m 2,940\u001b[38;5;8;49m \u001b[32;2H\u001b[23m\u001b[39;49m2025-02-24\u001b[32;13HSprockets\u001b[32;23HWest\u001b[32;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,520\u001b[38;5;8;49m \u001b[33;2H\u001b[23m\u001b[39;49m2025-01-14\u001b[33;13HGadgets\u001b[33;23HSouth\u001b[33;29H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,850\u001b[38;5;8;49m \u001b[34;2H\u001b[23m\u001b[39;49m2025-03-21\u001b[34;13HSprockets\u001b[34;23HWest\u001b[34;29H\u001b[3m\u001b[38;5;8;49m \u001b[36;59H\u001b[23m\u001b[1m\u001b[38;5;0;48;5;6mCol\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mCustomer → Col \u001b[37;29H \u001b[37;31H \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.293903, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.403428, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.513866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.623157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.734355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.844972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[64.953157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.062288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.171044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.277506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.385493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.492312, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.604282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.71478, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.827714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[65.934953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.041651, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.148548, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.256987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.364621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.474381, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.58384, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.710711, "o", "\u001b[36;48H\u001b[38;5;4;49m [Customer Col] \u001b[1m\u001b[38;5;0;48;5;6m [Date Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.818615, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[66.925978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.034783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.141116, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.247723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.356797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.464647, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.575, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.682954, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.789934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.86314, "o", "\u001b[3;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[3;32HCost\u001b[3;43H \u001b[3;104H \u001b[4;18H\u001b[24m Stark Enterprises\u001b[4m Soylent Ltd\u001b[24m Wonka Industries\u001b[4;66HCyberdyn\u001b[4;75H Syst\u001b[4;81Hms Acme Corp Oceanic Airlines\u001b[6;2H\u001b[38;5;6;48;5;237mGadgets North \u001b[22m\u001b[3m\u001b[38;5;8;48;5;237m \u001b[6;36H\u001b[1m\u001b[38;5;0;48;5;6m \u001b[6;48H\u001b[22m\u001b[38;5;8;48;5;237m \u001b[6;83H\u001b[38;5;15;48;5;237m 9,450\u001b[6;104H\u001b[38;5;8;48;5;237m \u001b[7;2H\u001b[23m\u001b[39;49m East \u001b[3m \u001b[7;30H10,850\u001b[38;5;8;49m \u001b[7;48H\u001b[39;49m 12,670\u001b[7;104H\u001b[38;5;8;49m \u001b[8;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[8;36H\u001b[39;49m 8,610\u001b[8;104H\u001b[38;5;8;49m \u001b[9;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[9;65H\u001b[39;49m \u001b[9;78H9,940\u001b[38;5;8;49m \u001b[39;49m 4,970\u001b[10;2H\u001b[23mWidgets North \u001b[3m\u001b[38;5;8;49m \u001b[10;59H \u001b[10;83H\u001b[39;49m 15,300\u001b[10;104H\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m East \u001b["]
|
||||||
|
[67.863188, "o", "3m 14,580\u001b[11;48H 17,520\u001b[11;94H\u001b[38;5;8;49m \u001b[12;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[12;36H\u001b[39;49m 12,000\u001b[12;76H\u001b[38;5;8;49m \u001b[12;104H \u001b[13;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[13;65H\u001b[39;49m 12,600\u001b[13;93H \u001b[13;99H 13,980\u001b[14;2H\u001b[23mSprockets North \u001b[3m\u001b[38;5;8;49m \u001b[14;104H \u001b[15;2H\u001b[23m\u001b[39;49m East \u001b[3m 2,940\u001b[15;48H 3,360\u001b[15;76H\u001b[38;5;8;49m \u001b[15;104H \u001b[16;2H\u001b[23m\u001b[39;49m South \u001b[3m\u001b[38;5;8;49m \u001b[16;36H\u001b[39;49m 2,170\u001b[16;104H\u001b[38;5;8;49m \u001b[17;2H\u001b[23m\u001b[39;49m West \u001b[3m\u001b[38;5;8;49m \u001b[17;65H\u001b[39;49m 2,520\u001b[17;93H 5,040\u001b[18;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────"]
|
||||||
|
[67.863311, "o", "────────────────────────────────────────────────────────\u001b[19;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990\u001b[20;2H\u001b[22m\u001b[39;49m \u001b[20;13H \u001b[20;23H \u001b[20;29H \u001b[21;2H \u001b[21;13H \u001b[21;23H \u001b[21;29H \u001b[22;2H \u001b[22;13H \u001b[22;23H \u001b[22;29H \u001b[23;2H \u001b[23;13H \u001b[23;23H \u001b[23;29H \u001b[24;2H \u001b[24;13H \u001b[24;23H \u001b[24;29H \u001b[25;2H \u001b[25;13H \u001b[25;23H \u001b[25;29H "]
|
||||||
|
[67.863477, "o", " \u001b[26;2H \u001b[26;13H \u001b[26;23H \u001b[26;29H \u001b[27;2H \u001b[27;13H \u001b[27;23H \u001b[27;29H \u001b[28;2H \u001b[28;13H \u001b[28;23H \u001b[28;29H \u001b[29;2H \u001b[29;13H \u001b[29;23H \u001b[29;29H \u001b[30;2H \u001b[30;13H \u001b[30;23H \u001b[30;29H \u001b[31;2H \u001b[31;13H \u001b[31;23H \u001b[31;29H \u001b[32;2H \u001b[32;13H \u001b[32;23H \u001b[32;29H \u001b[33;2H \u001b[33;13H \u001b[33;23H \u001b[33;29H "]
|
||||||
|
[67.86354, "o", " \u001b[34;2H \u001b[34;13H \u001b[34;23H \u001b[34;29H \u001b[36;71H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;8;49m [Date_Month ·] \u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mDate → None \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[67.967252, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.071254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.175017, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.280402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.384869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.490994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.594645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.698504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.802472, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[68.906976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.010324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.114855, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.218682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.321925, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.425338, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.530655, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.635327, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.739063, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.835646, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[69.939257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.042787, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.146403, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.221065, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.325462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.429923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.53483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.63921, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.699314, "o", "\u001b[3;12H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[3;26HCost\u001b[3;32H \u001b[3;110H \u001b[4;12H\u001b[24m Stark\u001b[4;19HEnterpris\u001b[4;29Hs\u001b[4m Soylent Ltd\u001b[24m Wonka\u001b[4;49HIndustries Cyb\u001b[4;64Hrdyne Syst\u001b[4;75Hms Acme Corp Oceanic Airlines Umbrella Co\u001b[6;12H\u001b[22m\u001b[3m\u001b[38;5;15;48;5;237m 10,850\u001b[1m\u001b[38;5;0;48;5;6m \u001b[6;37H8,610\u001b[22m\u001b[38;5;15;48;5;237m 12,670 9,940 9,450\u001b[6;88H 4,970 4,270\u001b[7;2H\u001b[23m\u001b[39;49mWidgets\u001b[7;12H\u001b[3m \u001b[7;24H14,580 12,000 \u001b[7;53H17,520 12,600 15,300 13,980 9,660\u001b[8;2H\u001b[23mSprockets\u001b[8;12H\u001b[3m 2,940 \u001b[8;37H2,170\u001b[8;43H 3,360 2,520\u001b[8;87H 5,040 4,410\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[70.699374, "o", "────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990 18,340\u001b[11;12H\u001b[22m\u001b[39;49m \u001b[11;18H \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;2H \u001b[14;12H \u001b[14;18H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;2H "]
|
||||||
|
[70.699547, "o", " \u001b[19;2H \u001b[36;98H\u001b[1m\u001b[38;5;0;48;5;6m·] \u001b[22m\u001b[38;5;8;49m [Date_Month ·] \u001b[39;49m \u001b[37;10H\u001b[38;5;0;48;5;5mRegion\u001b[37;17H→ None\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.801355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[70.903566, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.006414, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.109107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.211226, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.312839, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.415164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.517285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.618708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.720755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.82319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[71.926319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.028504, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.1316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.234303, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.33685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.438765, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.540175, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.635683, "o", "\u001b[36;89H\u001b[38;5;8;49m [Region ·] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Region → None Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.737283, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.839113, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[72.941124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.044152, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.146225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.248372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.350589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.452793, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.555584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.658217, "o", "\u001b[3;12H \u001b[1m\u001b[4m\u001b[38;5;3;49m Gadgets\u001b[24m Widgets Sprockets\u001b[22m\u001b[39;49m \u001b[4;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[5;2H\u001b[1m\u001b[38;5;6;48;5;237mCost Stark Enterprises \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 14,580 2,940\u001b[23m\u001b[39;48;5;237m \u001b[6;2H\u001b[39;49m Soylent Ltd \u001b[3m 8,610 12,000 2,170\u001b[23m \u001b[7;2H \u001b[7;10HWonka Industries \u001b[3m 12,670 17,520\u001b[7;49H3,360\u001b[23m \u001b[8;2H Cy"]
|
||||||
|
[73.658358, "o", "berdyne Systems \u001b[3m \u001b[8;31H9,940\u001b[8;37H 12,600\u001b[8;49H2,520\u001b[23m \u001b[9;2H Acme Corp \u001b[3m 9,450 15,300\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[10;2H Oceanic Airlines \u001b[3m 4,970 13,980 5,040\u001b[23m \u001b[11;10HUmbrella\u001b[11;19HCo\u001b[11;28H\u001b[3m 4,270 9,660 4,410\u001b[12;10H\u001b[23mGlobex\u001b[12;17HInc\u001b[12;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,100 4,410\u001b[13;10H\u001b[23mInitech\u001b[13;28H\u001b[3m 6,930 6,600\u001b[38;5;8;49m \u001b[14;2H\u001b[23m\u001b[39;49mRevenue\u001b[14;10HStark\u001b[14;16HEnterprises\u001b[14;28H\u001b[3m 15,500 24,300 4,200\u001b[15;10H\u001b[23mSoylent\u001b[15;18HLtd\u001b[15;28H\u001b[3m 12,300 20,000 3,100\u001b[16;10H\u001b[23mWonka\u001b[16;16HIndustries\u001b[16;28H\u001b[3m 18,100 29,200 4,800\u001b[17;10H\u001b[23mCyberdyne\u001b[17;20HSystems\u001b[17;28H\u001b[3m 14,200 21,000 3,600\u001b[18;10H\u001b[23mAcme\u001b[18;15HCorp\u001b[18;28H\u001b[3m 13,500 25,500\u001b[38;5;8;49m "]
|
||||||
|
[73.658473, "o", " \u001b[19;10H\u001b[23m\u001b[39;49mOceanic\u001b[19;18HAirlines\u001b[19;28H\u001b[3m 7,100 23,300 7,200\u001b[20;10H\u001b[23mUmbrella\u001b[20;19HCo\u001b[20;28H\u001b[3m 6,100 16,100 6,300\u001b[21;10H\u001b[23mGlobex\u001b[21;17HInc\u001b[21;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,500 6,300\u001b[22;10H\u001b[23mInitech\u001b[22;28H\u001b[3m 9,900 11,000\u001b[38;5;8;49m \u001b[23;2H\u001b[23m\u001b[39;49mProfit\u001b[23;10HStark\u001b[23;16HEnterprises\u001b[23;28H\u001b[3m 4,650 9,720 1,260\u001b[24;10H\u001b[23mSoylent\u001b[24;18HLtd\u001b[24;28H\u001b[3m 3,690 8,000 930\u001b[25;10H\u001b[23mWonka\u001b[25;16HIndustries\u001b[25;28H\u001b[3m 5,430 11,680 1,440\u001b[26;10H\u001b[23mCyberdyne\u001b[26;20HSystems\u001b[26;28H\u001b[3m 4,260 8,400 1,080\u001b[27;10H\u001b[23mAcme\u001b[27;15HCorp\u001b[27;28H\u001b[3m 4,050 10,200\u001b[38;5;8;49m \u001b[28;10H\u001b[23m\u001b[39;49mOceanic\u001b[28;18HAirlines\u001b[28;28H\u001b[3m 2,130 9,320 2,160\u001b[29;10H\u001b[23mUmbrella\u001b[29;19HCo\u001b[29;28H\u001b[3m 1,830 6,440 1,890\u001b[30;10H\u001b[23mGlobex\u001b[30;17HInc\u001b[30;28H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,400 1,890\u001b[31;10H\u001b[23mInitech\u001b[31;28H\u001b[3m 2,970 4,400\u001b[38;5;8;49m \u001b[32;2H\u001b[23"]
|
||||||
|
[73.658556, "o", "m\u001b[39;49mMargin\u001b[32;10HStark\u001b[32;16HEnterprises\u001b[32;28H\u001b[3m 30 40 30\u001b[33;10H\u001b[23mSoylent\u001b[33;18HLtd\u001b[33;28H\u001b[3m 30 40 30\u001b[34;10H\u001b[23mWonka\u001b[34;16HIndustries\u001b[34;28H\u001b[3m 30 40 30\u001b[36;32H\u001b[23m\u001b[38;5;2;49m [_Measure Row] [Customer Row] \u001b[36;74H\u001b[38;5;4;49m [Product Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.759967, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.862561, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[73.965616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.068047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.170187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.272638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.374756, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.4771, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.579809, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.682278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.784151, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.885701, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[74.988411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.090938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.193462, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.296878, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.398736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.501676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.604535, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.70834, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.811134, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[75.913363, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.015107, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.11667, "o", "\u001b[3;12H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;2H\u001b[22m\u001b[24m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m Stark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines Umbrella Co\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets \u001b[3m\u001b[38;5;0;48;5;6m 10,850\u001b[22m\u001b[38;5;15;48;5;237m 8,610 12,670 9,940 9,450 4,970 4,270\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets\u001b[7;10H \u001b[3m 14,580 12,000 \u001b[7;49H 17,520 12,600 15,300 13,980 "]
|
||||||
|
[76.116905, "o", " 9,660\u001b[8;2H\u001b[23mSprockets \u001b[3m 2,940\u001b[8;31H \u001b[8;37H2,170 \u001b[8;49H 3,360 2,520\u001b[38;5;8;49m \u001b[39;49m 5,040 4,410\u001b[9;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[10;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 22,780 33,550 25,060 24,750 23,990 18,340\u001b[11;10H\u001b[22m\u001b[39;49m \u001b[11;19H \u001b[11;28H \u001b[12;10H \u001b[12;17H \u001b[12;28H \u001b[13;10H \u001b[13;28H \u001b[14;2H \u001b[14;10H \u001b[14;16H \u001b[14;28H \u001b[15;10H \u001b[15;18H \u001b[15;28H \u001b[16;10H \u001b[16;"]
|
||||||
|
[76.117215, "o", "16H \u001b[16;28H \u001b[17;10H \u001b[17;20H \u001b[17;28H \u001b[18;10H \u001b[18;15H \u001b[18;28H \u001b[19;10H \u001b[19;18H \u001b[19;28H \u001b[20;10H \u001b[20;19H \u001b[20;28H \u001b[21;10H \u001b[21;17H \u001b[21;28H \u001b[22;10H \u001b[22;28H \u001b[23;2H \u001b[23;10H \u001b[23;16H \u001b[23;28H \u001b[24;10H \u001b[24;18H \u001b[24;28H \u001b[25;10H \u001b[25;16H \u001b[25;28H \u001b[26;10H \u001b[26;20H \u001b[26;28H \u001b[27;10H \u001b[27;15H \u001b[27;28H \u001b[28;10H \u001b[28;18H \u001b[28;28H \u001b[29;10H \u001b[29;19H \u001b[29;28H \u001b[30;10H \u001b[30;17H \u001b[30;28H \u001b[31;10H \u001b[31;28H \u001b[32;2H \u001b[32;10H \u001b[32;16H \u001b[32;28H "]
|
||||||
|
[76.117371, "o", " \u001b[33;10H \u001b[33;18H \u001b[33;28H \u001b[34;10H \u001b[34;16H \u001b[34;28H \u001b[36;32H\u001b[38;5;4;49m [_Measure Col] [Customer Col] \u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.220162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.322015, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.423613, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.525244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.627242, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.728873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.831933, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[76.934917, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.036315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.13847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.242733, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.345432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.448448, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.550404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.653764, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.756099, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.859408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[77.961709, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.063238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.165254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.267493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.369832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.472128, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.574391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.676144, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.777862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.880914, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[78.983394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.085396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.188456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.290135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.391212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.492775, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.595048, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.697259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.800184, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[79.902419, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.005145, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.107077, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.209791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.311753, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.348684, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.450215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.553719, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.656004, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.757524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.8535, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[80.955594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.057237, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.159725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.261826, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.363603, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.406014, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.507994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.609703, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.712177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[81.798741, "o", "\u001b[?1049l\u001b[?25h"]
|
||||||
|
[81.80075, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[81.842957, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[81.985112, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:08:28\r\n16027:% \u001b[K"]
|
||||||
|
[81.985205, "o", "\u001b[?2004h"]
|
||||||
|
[82.780045, "o", "\u001b[?2004l\r\r\n"]
|
||||||
210
docs/casts/import.cast
Normal file
210
docs/casts/import.cast
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
{"version": 2, "width": 120, "height": 37, "timestamp": 1775770896, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}}
|
||||||
|
[0.236073, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[0.243595, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[0.36932, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 14:41:37\r\n16023:% \u001b[K"]
|
||||||
|
[0.369422, "o", "\u001b[?2004h"]
|
||||||
|
[6.04003, "o", "\u001b[3m./target/release/improvise import examples/demo.csv\u001b[23m"]
|
||||||
|
[6.780344, "o", "\u001b[51D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mo\u001b[23mr\u001b[23mt\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mc\u001b[23ms\u001b[23mv"]
|
||||||
|
[6.780389, "o", "\u001b[?2004l\r\r\n"]
|
||||||
|
[6.781567, "o", "\u001b]0;./target/release/improvise import examples/demo.csv\u0007\u001b[2 q"]
|
||||||
|
[6.824759, "o", "\u001b[?1049h"]
|
||||||
|
[6.825908, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · New Model ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[3;6H\u001b[1m\u001b[4m\u001b[38;5;3;49m Value\u001b[3;120H\u001b[22m\u001b[24m\u001b[39;49m│\u001b[4;1H│\u001b[38;5;8;49m───────────────────\u001b[38;5;5;49m┌ Import Wizard — Review Fields ───────────────────────────────────────────────┐\u001b[38;5;8;49m───────────────────\u001b[39;49m│\u001b[5;1H│\u001b[5;21H\u001b[38;5;5;49m│\u001b[38;5;3;49mReview field proposals (Space toggle, c cycle kin"]
|
||||||
|
[6.825948, "o", "d):\u001b[5;100H\u001b[38;5;5;49m│\u001b[5;120H\u001b[39;49m│\u001b[6;1H│\u001b[6;21H\u001b[38;5;5;49m│\u001b[4m\u001b[38;5;7;49m Field Kind Accept\u001b[6;100H\u001b[24m\u001b[38;5;5;49m│\u001b[6;120H\u001b[39;49m│\u001b[7;1H│\u001b[7;21H\u001b[38;5;5;49m│\u001b[1m\u001b[38;5;0;48;5;6m Cost Measure (numeric) [✓]\u001b[7;100H\u001b[22m\u001b[38;5;5;49m│\u001b[7;120H\u001b[39;49m│\u001b[8;1H│\u001b[8;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Customer Category (dimension) [✓]\u001b[8;100H\u001b[38;5;5;49m│\u001b[8;120H\u001b[39;49m│\u001b[9;1H│\u001b[9;21H\u001b[38;5;5;49m│ Date Time Category [✓]\u001b[9;100H│\u001b[9;120H\u001b[39;49m│\u001b[10;1H│\u001b[10;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Product Category (dimension) [✓]\u001b[10;100H\u001b[38;5;5;49m│\u001b[10;120H\u001b[39;49m│\u001b[11;1H│\u001b[11;21H\u001b[38;5;5;49m│\u001b[38;5;2;49m Region Category (dimension) [✓]\u001b[11;100H\u001b[38;5;5;49m│\u001b[11;120H\u001b[39;49m│\u001b[12;1H│\u001b[12;21H\u001b[38;5;5;49m│\u001b[38;5;6;49m Revenue Measure (numeric) [✓]\u001b[12;100H\u001b[38;5;5;49m│\u001b[12;120H\u001b[39;49m│\u001b[13;1H│\u001b[13;2"]
|
||||||
|
[6.826095, "o", "1H\u001b[38;5;5;49m│\u001b[13;100H│\u001b[13;120H\u001b[39;49m│\u001b[14;1H│\u001b[14;21H\u001b[38;5;5;49m│\u001b[14;100H│\u001b[14;120H\u001b[39;49m│\u001b[15;1H│\u001b[15;21H\u001b[38;5;5;49m│\u001b[15;100H│\u001b[15;120H\u001b[39;49m│\u001b[16;1H│\u001b[16;21H\u001b[38;5;5;49m│\u001b[16;100H│\u001b[16;120H\u001b[39;49m│\u001b[17;1H│\u001b[17;21H\u001b[38;5;5;49m│\u001b[17;100H│\u001b[17;120H\u001b[39;49m│\u001b[18;1H│\u001b[18;21H\u001b[38;5;5;49m│\u001b[18;100H│\u001b[18;120H\u001b[39;49m│\u001b[19;1H│\u001b[19;21H\u001b[38;5;5;49m│\u001b[19;100H│\u001b[19;120H\u001b[39;49m│\u001b[20;1H│\u001b[20;21H\u001b[38;5;5;49m│\u001b[20;100H│\u001b[20;120H\u001b[39;49m│\u001b[21;1H│\u001b[21;21H\u001b[38;5;5;49m│\u001b[21;100H│\u001b[21;120H\u001b[39;49m│\u001b[22;1H│\u001b[22;21H\u001b[38;5;5;49m│\u001b[22;100H│\u001b[22;120H\u001b[39;49m│\u001b[23;1H│\u001b[23;21H\u001b[38;5;5;49m│\u001b[23;100H│\u001b[23;120H\u001b[39;49m│\u001b[24;1H│\u001b[24;21H\u001b[38;5;5;49m│\u001b[24;100H│\u001b[24;120H\u001b[39;49m│\u001b[25;1H│\u001b[25;21H\u001b[38;5;5;49m│\u001b[25;100H│\u001b[25;120H\u001b[39;49m│\u001b[26;1H│\u001b[26;21H\u001b[38;5;5;49m│\u001b[26;100H│\u001b[26;120H\u001b[39;49m│\u001b[27;1H│\u001b[27;21H\u001b[38;5;5;49m│\u001b[27;100H│\u001b[27;120H\u001b[39;49m│\u001b[28;1H│\u001b[28;21H\u001b[38;5;5;49m│\u001b[28;100H│\u001b[28;120H\u001b[39;49m│"]
|
||||||
|
[6.826171, "o", "\u001b[29;1H│\u001b[29;21H\u001b[38;5;5;49m│\u001b[29;100H│\u001b[29;120H\u001b[39;49m│\u001b[30;1H│\u001b[30;21H\u001b[38;5;5;49m│\u001b[30;100H│\u001b[30;120H\u001b[39;49m│\u001b[31;1H│\u001b[31;21H\u001b[38;5;5;49m│\u001b[31;100H│\u001b[31;120H\u001b[39;49m│\u001b[32;1H│\u001b[32;21H\u001b[38;5;5;49m│\u001b[38;5;8;49mEnter: next Space: toggle c: cycle kind Esc: cancel\u001b[32;100H\u001b[38;5;5;49m│\u001b[32;120H\u001b[39;49m│\u001b[33;1H│\u001b[33;21H\u001b[38;5;5;49m└──────────────────────────────────────────────────────────────────────────────┘\u001b[33;120H\u001b[39;49m│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H"]
|
||||||
|
[6.826265, "o", "\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;2;49m [_Index Row] \u001b[38;5;4;49m [_Dim Col] \u001b[38;5;8;49m [_Measure ·] Ctrl+↑↓←→ to move tiles\u001b[37;1H\u001b[38;5;0;48;5;8m IMPORT Space:toggle c:cycle Enter:next Esc:cancel Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.927868, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.028723, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.130263, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.231693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.333159, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.433716, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.535238, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.636386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.73786, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.839624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.941139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.04274, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.144036, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.245449, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.346985, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.448858, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.550336, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.576166, "o", "\u001b[4;39H\u001b[38;5;5;49mDate Components \u001b[5;22H\u001b[38;5;3;49mS\u001b[5;24Hlect\u001b[5;29Hdate component\u001b[5;45Hto extract (Spa\u001b[5;61He toggle):\u001b[39;49m \u001b[6;22H\u001b[1m\u001b[38;5;5;49m Date (format: %Y-%m-%d)\u001b[22m\u001b[39;49m \u001b[7;24H\u001b[1m\u001b[38;5;0;48;5;6m [ ]\u001b[7;30HYear\u001b[22m\u001b[39;49m \u001b[8;22H\u001b[38;5;8;49m [ ] Month\u001b[39;49m \u001b[9;22H\u001b[38;5;8;49m [ ] Quarter\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[12;22H \u001b[32;22H\u001b[38;5;8;49mSpace\u001b[32;29Htoggle Enter: next\u001b[32;50HEsc: \u001b[32;56Hancel\u001b[39;49m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.677413, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.778735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.880265, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.981784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.083632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.185292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.286565, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.388026, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.489709, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.591377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.692806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.794433, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.895905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.948598, "o", "\u001b[4;39H\u001b[38;5;5;49mFormulas ───────\u001b[5;22H\u001b[38;5;3;49mD\u001b[5;24Hfine\u001b[5;29Hformulas (optional):\u001b[39;49m \u001b[6;22H\u001b[38;5;8;49m (no formulas yet)\u001b[39;49m \u001b[7;22H \u001b[8;22H\u001b[38;5;8;49mExamples:\u001b[39;49m \u001b[9;24H\u001b[38;5;8;49mDiff = Cos\u001b[9;35H - Revenue\u001b[10;22H Total = SUM(Cost)\u001b[11;22H Ratio = Cost / Revenue\u001b[32;22Hn: n\u001b[32;27Hw\u001b[32;29Hf\u001b[32;31Hrmula\u001b[32;37H d: delete \u001b[32;49HEnter: next Esc: cancel\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.050037, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.151224, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.252897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.354645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.456403, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.558415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.659907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.761363, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.862866, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.964162, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.06573, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.166744, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.267259, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.368721, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.382289, "o", "\u001b[4;39H\u001b[38;5;5;49mName Model \u001b[5;22H\u001b[38;5;3;49mModel name:\u001b[39;49m \u001b[6;22H\u001b[38;5;2;49m> Imported Model█\u001b[39;49m \u001b[8;23H\u001b[38;5;8;49mnter to import, Esc to cancel\u001b[9;22H\u001b[39;49m \u001b[10;22H \u001b[11;22H \u001b[32;22H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.482638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.584593, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.686176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.788053, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.889981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.991675, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.093291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.194383, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.295384, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.395968, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.497456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.599038, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.700412, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.801355, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.902735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.922022, "o", "\u001b[1;17H\u001b[1m\u001b[38;5;0;48;5;4mImported Model\u001b[1;32H[+]\u001b[3;2H\u001b[22m\u001b[38;5;5;49m [Product = Gadgets | Region = South] \u001b[4;2H\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-02-03\u001b[24m 2025-02-25 2025-01-22 2025-03-28 2025-01-31 2025-01-27 2025-01-11 2025-02-10 2025-03-14\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mAcme Corp \u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWonka\u001b[7;8HIndustries\u001b[7;20H\u001b[38;5;8;49m \u001b[8;2H\u001b[39;49mInit"]
|
||||||
|
[12.922264, "o", "ech\u001b[8;20H\u001b[38;5;8;49m \u001b[8;28H \u001b[8;31H \u001b[8;39H \u001b[8;43H \u001b[8;46H \u001b[9;2H\u001b[39;49mUmbrella\u001b[9;11HCo\u001b[9;20H\u001b[38;5;8;49m \u001b[10;2H\u001b[39;49mGlobex\u001b[10;9HInc\u001b[10;20H\u001b[38;5;8;49m \u001b[11;2H\u001b[39;49mCyberdyne\u001b[11;12HSystems\u001b[11;20H\u001b[38;5;8;49m \u001b[12;2H\u001b[39;49mSoylent\u001b[12;10HLtd\u001b[12;20H\u001b[38;5;8;49m \u001b[13;2H\u001b[39;49mStark\u001b[13;8HEnterprises\u001b[13;20H\u001b[38;5;8;49m \u001b[14;2H\u001b[39;49mOceanic\u001b[14;10HAirlines\u001b[14;20H\u001b[38;5;8;49m "]
|
||||||
|
[12.922541, "o", " \u001b[15;2H──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[16;2H\u001b[1m\u001b[38;5;3;49mTotal 0 0 0 0 0 0 0 0 0\u001b[17;21H\u001b[22m\u001b[39;49m \u001b[17;100H \u001b[18;21H \u001b[18;100H \u001b[19;21H \u001b[19;100H \u001b[20;21H \u001b[20;100H \u001b[21;21H \u001b[21;100H \u001b[22;21H \u001b[22;100H \u001b[23;21H \u001b[23;100H \u001b[24;21H \u001b[24;100H \u001b[25;21H \u001b[25;100H \u001b[26;21H \u001b[26;100H \u001b[27;21H \u001b[27;100H \u001b[28;21H \u001b[28;100H \u001b[29;21H \u001b[29;100H \u001b[30;21H \u001b[30;100H \u001b[31;21H \u001b[31;100H \u001b[32;21H \u001b[32;100H \u001b[33;21H \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] [_Measure ·] \u001b[38;5;2;"]
|
||||||
|
[12.922705, "o", "49m [Customer Row] \u001b[38;5;4;49m [Date Col] \u001b[38;5;5;49m [Product Pag] [Region Pag] \u001b[37;3H\u001b[38;5;0;48;5;8mNORMAL\u001b[37;11HImport succ\u001b[37;23Hssful! Press :w <path>\u001b[37;46Hto save. \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.025884, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.129779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.233754, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.336214, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.440232, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.543166, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.645506, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.748407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.851524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.954923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.058244, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.160825, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.264044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.366997, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.471142, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.573197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.676755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.77958, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.883251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.987297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.090783, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.194845, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.298104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.40055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.469084, "o", "\u001b[4;14H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01-14\u001b[24m 2025-0\u001b[4;33H-1\u001b[4;36H 2025-03-18\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mUmbrella Co\u001b[6;14H\u001b[38;5;0;48;5;6m \u001b[6;25H\u001b[22m\u001b[38;5;15;48;5;237m 10,370\u001b[6;47H\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mS\u001b[7;4Hylent Ltd \u001b[7;20H9,350\u001b[7;36H 11,560 \u001b[8;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[9;2H\u001b[1m\u001b[38;5;3;49mTotal 9,350 10,370 11,560\u001b[22m\u001b[39;49m "]
|
||||||
|
[15.469306, "o", " \u001b[10;2H \u001b[10;9H \u001b[10;20H \u001b[11;2H \u001b[11;12H \u001b[11;20H \u001b[12;2H \u001b[12;10H \u001b[12;20H \u001b[13;2H \u001b[13;8H \u001b[13;20H \u001b[14;2H \u001b[14;10H \u001b[14;20H \u001b[15;2H \u001b[16;2H \u001b[37;11H\u001b[38;5;0;48;5;8mHiding\u001b[37;18Hempty rows/columns \u001b[37;39H \u001b[37;46H \u001b[37;49H \u001b[39"]
|
||||||
|
[15.469451, "o", "m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.570197, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.671986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.780803, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.875273, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.976917, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.078891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.179977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.281679, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.383194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.483934, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.585778, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.686585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.78829, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.890249, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.992328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.093415, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.19456, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.296044, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.398176, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.500056, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.600932, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.701717, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.8031, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.904288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.005978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.107622, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.209146, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.310776, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.412516, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.51388, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.615399, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.716846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.817638, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.91938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.020843, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.122743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.150145, "o", "\u001b[4;14H\u001b[1m\u001b[38;5;3;49m 2025-01-14\u001b[4m 2025-02-15\u001b[6;14H\u001b[22m\u001b[24m\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m 10,370\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.252006, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.353965, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.456545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.658912, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.742183, "o", "\u001b[4;25H\u001b[1m\u001b[38;5;3;49m 2025-02-15\u001b[4m 2025-03-18\u001b[6;25H\u001b[22m\u001b[24m\u001b[38;5;15;48;5;237m 10,370\u001b[1m\u001b[38;5;0;48;5;6m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.843853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.945171, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.046281, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.147441, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.18382, "o", "\u001b[6;2HUmbrella Co \u001b[38;5;8;49m \u001b[39;49m 10,370\u001b[38;5;8;49m \u001b[39;49m \u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mSoylent Ltd \u001b[22m\u001b[38;5;15;48;5;237m 9,350\u001b[38;5;8;48;5;237m \u001b[1m\u001b[38;5;0;48;5;6m 11,560\u001b[22m\u001b[39;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.285624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.386859, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.488387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.590209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.651419, "o", "\u001b[4;25H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-02-15\u001b[24m 2025-03-18\u001b[7;25H\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;15;48;5;237m 11,560\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.753642, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.855676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.957502, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.059422, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.160676, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.192868, "o", "\u001b[4;14H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01-14\u001b[24m 2025-02-15\u001b[7;14H\u001b[38;5;0;48;5;6m 9,350\u001b[22m\u001b[38;5;8;48;5;237m \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.294455, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.396139, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.497157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.598115, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.700028, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.800846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.902346, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.004411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.085017, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.186447, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.288585, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.390454, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.49188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.533815, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.635568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.73671, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.838266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.921803, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.023789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.125305, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.226879, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.329421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.430789, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.531493, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.633286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.73513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.836317, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.937902, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.039833, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.141634, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.242633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.303273, "o", "\u001b[?1049l"]
|
||||||
|
[24.303377, "o", "\u001b[?25h"]
|
||||||
|
[24.304571, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[24.338067, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[24.468723, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 14:42:01\r\n16024:% \u001b[K"]
|
||||||
|
[24.468837, "o", "\u001b[?2004h"]
|
||||||
|
[25.113653, "o", "e"]
|
||||||
|
[25.344547, "o", "\bex"]
|
||||||
|
[25.514123, "o", "i"]
|
||||||
|
[25.686254, "o", "t"]
|
||||||
|
[25.857353, "o", "\u001b[?2004l\r\r\n"]
|
||||||
|
[25.858441, "o", "\u001b]0;exit\u0007\u001b[2 q"]
|
||||||
522
docs/casts/pivot.cast
Normal file
522
docs/casts/pivot.cast
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
{"version": 2, "width": 120, "height": 37, "timestamp": 1775772168, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/zsh", "TERM": "screen-256color"}}
|
||||||
|
[0.195175, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[0.202866, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[0.324014, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:02:48\r\n16023:% \u001b[K"]
|
||||||
|
[0.324144, "o", "\u001b[?2004h"]
|
||||||
|
[2.844656, "o", "\u001b[3m./target/release/improvise examples/demo.improv\u001b[23m"]
|
||||||
|
[4.032794, "o", "\u001b[47D\u001b[23m.\u001b[23m/\u001b[23mt\u001b[23ma\u001b[23mr\u001b[23mg\u001b[23me\u001b[23mt\u001b[23m/\u001b[23mr\u001b[23me\u001b[23ml\u001b[23me\u001b[23ma\u001b[23ms\u001b[23me\u001b[23m/\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[23mi\u001b[23ms\u001b[23me\u001b[23m \u001b[23me\u001b[23mx\u001b[23ma\u001b[23mm\u001b[23mp\u001b[23ml\u001b[23me\u001b[23ms\u001b[23m/\u001b[23md\u001b[23me\u001b[23mm\u001b[23mo\u001b[23m.\u001b[23mi\u001b[23mm\u001b[23mp\u001b[23mr\u001b[23mo\u001b[23mv\u001b[?2004l"]
|
||||||
|
[4.03284, "o", "\r\r\n"]
|
||||||
|
[4.03374, "o", "\u001b]0;./target/release/improvise examples/demo.improv\u0007\u001b[2 q"]
|
||||||
|
[4.07901, "o", "\u001b[?1049h"]
|
||||||
|
[4.081296, "o", "\u001b[1;1H\u001b[1m\u001b[38;5;0;48;5;4m improvise · Acme Sales Demo (demo.improv) ?:help :q quit \u001b[2;1H\u001b[22m\u001b[39;49m┌\u001b[2;3HView:\u001b[2;9HDefault\u001b[2;17H───────────────────────────────────────────────────────────────────────────────────────────────────────┐\u001b[3;1H│\u001b[38;5;5;49m [Customer = Stark Enterprises] \u001b[3;120H\u001b[39;49m│\u001b[4;1H│\u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[24m Revenue Profit \u001b[4;120H\u001b[22m\u001b[39;49m│\u001b[5;1H│\u001b[5;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[5;120H\u001b[22m\u001b[39;49m│\u001b[6;1H│\u001b[38;5;8;49m────────────────────────────────────────────────"]
|
||||||
|
[4.081395, "o", "──────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[7;1H│\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[39;49m│\u001b[8;1H│\u001b[8;12HEast\u001b[8;18H\u001b[3m 5,180\u001b[38;5;8;49m \u001b[39;49m 5,670 7,400\u001b[38;5;8;49m \u001b[39;49m 8,100 2,220\u001b[38;5;8;49m \u001b[39;49m 2,430\u001b[8;120H\u001b[23m│\u001b[9;1H│\u001b[9;12HSouth\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;120H\u001b[23m\u001b[39;49m│\u001b[10;1H│\u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;120H\u001b[23m\u001b[39;49m│\u001b[11;1H│Widgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[4.081493, "o", " \u001b[11;120H\u001b[23m\u001b[39;49m│\u001b[12;1H│\u001b[12;12HEast\u001b[12;18H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 7,500 11,800\u001b[38;5;8;49m \u001b[39;49m 12,500 4,720\u001b[38;5;8;49m \u001b[39;49m 5,000\u001b[12;120H\u001b[23m│\u001b[13;1H│\u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;120H\u001b[23m\u001b[39;49m│\u001b[14;1H│\u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;120H\u001b[23m\u001b[39;49m│\u001b[15;1H│Sprockets\u001b[15;12HNorth\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;120H\u001b[23m\u001b[39;49m│\u001b[16;1H│\u001b[16;12HEast\u001b[16;18H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 2,940\u001b[38;5;8;49m \u001b[39;49m 4,200\u001b[38;5;8;49m \u001b[39;49m 1,260\u001b[38;5;8;49m \u001b[16;120H\u001b[23m\u001b[39;49m│\u001b[17;1H│\u001b[17;12HSouth\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;120H\u001b[23m\u001b[39;49m│\u001b[18;1H│\u001b[18;12HWest"]
|
||||||
|
[4.081554, "o", "\u001b[18;18H\u001b[3m\u001b[38;5;8;49m \u001b[18;120H\u001b[23m\u001b[39;49m│\u001b[19;1H│\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39;49m│\u001b[20;1H│\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[20;120H\u001b[22m\u001b[39;49m│\u001b[21;1H│\u001b[21;120H│\u001b[22;1H│\u001b[22;120H│\u001b[23;1H│\u001b[23;120H│\u001b[24;1H│\u001b[24;120H│\u001b[25;1H│\u001b[25;120H│\u001b[26;1H│\u001b[26;120H│\u001b[27;1H│\u001b[27;120H│\u001b[28;1H│\u001b[28;120H│\u001b[29;1H│\u001b[29;120H│\u001b[30;1H│\u001b[30;120H│\u001b[31;1H│\u001b[31;120H│\u001b[32;1H│\u001b[32;120H│\u001b[33;1H│\u001b[33;120H│\u001b[34;1H│\u001b[34;120H│\u001b[35;1H└───────────────────"]
|
||||||
|
[4.081585, "o", "───────────────────────────────────────────────────────────────────────────────────────────────────┘\u001b[36;1H\u001b[38;5;7;49m Tiles: \u001b[36;10H\u001b[38;5;8;49m [_Index ·] [_Dim ·] \u001b[38;5;4;49m [_Measure Col] \u001b[38;5;5;49m [Customer Pag] \u001b[38;5;8;49m [Date ·] \u001b[38;5;2;49m [Product Row] [Region Row] \u001b[38;5;4;49m [Date_Month Col] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL hjkl:nav i:edit R:records P:prune F/C/V:panels T:tiles [:]:page >:drill ::cmd Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.183606, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.286393, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.390472, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.492202, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.594054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.696758, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.799994, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[4.902051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.005122, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.108799, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.212099, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.314466, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.417267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.518871, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.621814, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.711323, "o", "\u001b[4;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;21HCost \u001b[4;41H\u001b[24m Revenue \u001b[4;67HProfit \u001b[4;89H\u001b[22m\u001b[39;49m \u001b[5;17H\u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-02 2025-03 2025-01 2025-02 2025-03 2025-01 2025-02 2025-03\u001b[22m\u001b[39;49m \u001b[7;12H\u001b[1m\u001b[38;5;6;48;5;237mEas\u001b[7;16H \u001b[3m\u001b[38;5;0;48;5;6m \u001b[7;20H5,180\u001b[22m\u001b[38;5;8;48;5;237m \u001b[7;33H\u001b[38;5;15;48;5;237m 5,670 7,400\u001b[7;57H 8,100 2,220\u001b[7;81H 2,430\u001b[23m\u001b[39;48;5;237m \u001b[8;2H\u001b[39;49mWidgets\u001b[8;17H\u001b[3m \u001b[8;20H7,080\u001b[38;5;8;49m \u001b[8;33H\u001b[39;49m \u001b[8;36H7,500 \u001b[8;43H11,80\u001b[8;49H\u001b[38;5;8;49m \u001b[8;57H\u001b[39;49m \u001b[8;59H12,50\u001b[8;65H \u001b[8;68H4,7\u001b[8;72H0\u001b[38;5;8;49m \u001b[8;81H\u001b[39;49m \u001b[8;84H5,000\u001b[23m \u001b[9;2HSprockets\u001b[9;12HEas\u001b[9;16H \u001b[3m\u001b[38;5;8;49m \u001b[9;25H\u001b[39;49m 2,940\u001b[9;49H 4,200\u001b[9;73H 1,260\u001b[9;89H\u001b[23m \u001b[10;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────"]
|
||||||
|
[5.711359, "o", "────────────────────────────────\u001b[11;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 2,940 13,170 19,200 4,200 20,600 6,940 1,260 7,430\u001b[22m\u001b[39;49m \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;12H \u001b[18;18H \u001b[19;2H \u001b[20;2H "]
|
||||||
|
[5.711509, "o", " \u001b[37;11H\u001b[38;5;0;48;5;8mHiding empty rows/columns \u001b[37;40H \u001b[37;49H \u001b[37;63H \u001b[37;72H \u001b[37;82H \u001b[37;91H \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.813121, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[5.91487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.017513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.118507, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.220366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.322685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.424938, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.526253, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.628282, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.730046, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.831779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[6.933073, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.034386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.136138, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.237624, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.339346, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.44047, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.54239, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.569716, "o", "\u001b[36;10H\u001b[1m\u001b[38;5;0;48;5;6m [_Index ·] \u001b[37;1H\u001b[22m\u001b[38;5;0;48;5;5m TILES Hiding empty rows/columns Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.670732, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.772512, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.873528, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[7.974952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.076658, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.178544, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.279559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.381257, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.482995, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.585266, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.686385, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.788117, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.890743, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[8.988024, "o", "\u001b[36;10H\u001b[38;5;8;49m [_Index ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Dim ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.089784, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.192696, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.295117, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.397096, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.448442, "o", "\u001b[36;22H\u001b[38;5;8;49m [_Dim ·] \u001b[1m\u001b[38;5;0;48;5;6m [_Measure Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.550536, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.652481, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.754194, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.778141, "o", "\u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.880846, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[9.983079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.084998, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.110623, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.212706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.314212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.415972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.517497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.62025, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.721782, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.797927, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[10.90034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.00188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.1041, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.20582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.278559, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.38008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.481759, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.583563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.627199, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.729661, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.831725, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[11.933955, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.034905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.137145, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.238572, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.34034, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.441291, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.543123, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.644683, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.746513, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.847672, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[12.949646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.051292, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.153276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.256497, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.358742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.46022, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.56178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.663495, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.764432, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.86667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[13.968591, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.069929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.172681, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.274414, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.377178, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.478959, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.58086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.682785, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.769142, "o", "\u001b[1;47H\u001b[1m\u001b[38;5;0;48;5;4m[+]\u001b[4;17H\u001b[22m\u001b[39;49m \u001b[4;28H\u001b[1m\u001b[4m\u001b[38;5;3;49mCost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[23m \u001b[8;17H2025-01 \u001b[3m 7,080 \u001b[8;34H11,80\u001b[8;40H \u001b[8;42H4,720\u001b[23m \u001b[9;2H \u001b[9;12H \u001b[9;17"]
|
||||||
|
[14.769179, "o", "H2025-03 \u001b[9;27H\u001b[3m7,500 12,500 5,000\u001b[23m \u001b[10;2HSprockets East 2025-02 \u001b[3m 2,940 4,200 1,260\u001b[23m \u001b[11;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b[36;116H\u001b[38;5;0;48;5;6mR\u001b[36;118Hw\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month →\u001b[37;23HR\u001b[37;26H \u001b[37;110HDefault \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.870531, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[14.972288, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.074003, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.175981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.277102, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.378888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.480112, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.581487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.681928, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.784289, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.886179, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[15.988225, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.08923, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.191357, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.293039, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.395185, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.496391, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.598014, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.699411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.800941, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[16.902697, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.004348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.105735, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.207365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.30897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.410872, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.511981, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.613786, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.715365, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.817026, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[17.918686, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.020006, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.121899, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.223486, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.324746, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.426154, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.527582, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.629511, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.731055, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.832593, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[18.934443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.036075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.137379, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.239173, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.310363, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;2;49m [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.412423, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.514646, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.6164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.718133, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.770074, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.872124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[19.973223, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.07488, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.129894, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.231558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.333386, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.434821, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.536254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.545792, "o", "\u001b[36;48H\u001b[1m\u001b[38;5;0;48;5;6m [Customer Pag] \u001b[22m\u001b[38;5;8;49m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.647361, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.74837, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.849862, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[20.912245, "o", "\u001b[3;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost \u001b[4;25H\u001b[22m\u001b[24m\u001b[39;49m \u001b[4;27H\u001b[1m\u001b[4m\u001b[38;5;3;49mStark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines\u001b[6;12H\u001b[38;5;6;48;5;237mNor\u001b[6;16Hh 2025-01 \u001b[6;27H\u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[38;5;15;48;5;237m 4,340\u001b[38;5;8;48;5;237m \u001b[7;17H\u001b[23m\u001b[39;49m 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,110\u001b[38;5;8;49m \u001b[8;2H\u001b[23m\u001b[39;49m \u001b[8;12H \u001b[8;17H 2025-03 \u001b[3m\u001b[38;5;8;49m \u001b[9;12H\u001b[23m\u001b[39;49mEast\u001b[9;17H 2025-01 \u001b[9;27H\u001b[3m \u001b[9;34H 5,180\u001b[38;5;8;49m \u001b[39;49m 6,230\u001b[38;5;8;49m \u001b[10;2H\u001b[23m"]
|
||||||
|
[20.912321, "o", "\u001b[39;49m \u001b[10;12H \u001b[10;17H 2025-02 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,440\u001b[38;5;8;49m \u001b[11;2H\u001b[23m\u001b[39;49m 2025-03 \u001b[3m 5,670\u001b[38;5;8;49m \u001b[23m\u001b[39;49m \u001b[12;2H South 2025-01 \u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,850\u001b[38;5;8;49m \u001b[13;18H\u001b[23m\u001b[39;49m2025-02\u001b[13;26H\u001b[3m\u001b[38;5;8;49m \u001b[14;18H\u001b[23m\u001b[39;49m2025-03\u001b[14;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,760\u001b[38;5;8;49m \u001b[15;12H\u001b[23m\u001b[39;49mWest\u001b[15;18H2025-01\u001b[15;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 4,690\u001b[38;5;8;49m \u001b[16;18H\u001b[23m\u001b[39;49m2025-02\u001b[16;26H\u001b[3m"]
|
||||||
|
[20.912448, "o", "\u001b[38;5;8;49m \u001b[39;49m 4,970\u001b[17;18H\u001b[23m2025-03\u001b[17;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,250\u001b[38;5;8;49m \u001b[18;2H\u001b[23m\u001b[39;49mWidgets\u001b[18;12HNorth\u001b[18;18H2025-01\u001b[18;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 7,200\u001b[38;5;8;49m \u001b[19;18H\u001b[23m\u001b[39;49m2025-02\u001b[19;26H\u001b[3m\u001b[38;5;8;49m \u001b[20;18H\u001b[23m\u001b[39;49m2025-03\u001b[20;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 8,100\u001b[38;5;8;49m \u001b[21;12H\u001b[23m\u001b[39;49mEast\u001b[21;18H2025-01\u001b[21;26H\u001b[3m 7,080\u001b[38;5;8;49m \u001b[39;49m 8,520\u001b[38;5;8;49m \u001b[22;18H\u001b[23m\u001b[39;49m2025-02\u001b[22;26H\u001b[3m\u001b[38;5;8;49m "]
|
||||||
|
[20.912533, "o", " \u001b[39;49m 9,000\u001b[38;5;8;49m \u001b[23;18H\u001b[23m\u001b[39;49m2025-03\u001b[23;26H\u001b[3m 7,500\u001b[38;5;8;49m \u001b[24;12H\u001b[23m\u001b[39;49mSouth\u001b[24;18H2025-01\u001b[24;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[25;18H\u001b[23m\u001b[39;49m2025-02\u001b[25;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,120\u001b[38;5;8;49m \u001b[26;18H\u001b[23m\u001b[39;49m2025-03\u001b[26;26H\u001b[3m\u001b[38;5;8;49m \u001b[27;12H\u001b[23m\u001b[39;49mWest\u001b[27;18H2025-01\u001b[27;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,300\u001b[28;18H\u001b[23m2025-02\u001b[28;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 6,720\u001b[38;5;8;49m "]
|
||||||
|
[20.912627, "o", " \u001b[29;18H\u001b[23m\u001b[39;49m2025-03\u001b[29;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 5,880\u001b[38;5;8;49m \u001b[39;49m 7,680\u001b[30;2H\u001b[23mSprockets\u001b[30;12HNorth\u001b[30;18H2025-01\u001b[30;26H\u001b[3m\u001b[38;5;8;49m \u001b[31;18H\u001b[23m\u001b[39;49m2025-02\u001b[31;26H\u001b[3m\u001b[38;5;8;49m \u001b[32;12H\u001b[23m\u001b[39;49mEast\u001b[32;18H2025-02\u001b[32;26H\u001b[3m 2,940\u001b[38;5;8;49m \u001b[33;18H\u001b[23m\u001b[39;49m2025-03\u001b[33;26H\u001b[3m\u001b[38;5;8;49m \u001b[39;49m 3,360\u001b[38;5;8;49m \u001b[34;12H\u001b[23m\u001b[39;49mSouth\u001b[34;18H2025-01\u001b[34;26H\u001b[3m\u001b[38;5;8;49m \u001b[36;59H\u001b[23m\u001b[1m\u001b[38;5;0;48;5;6mCol\u001b[37;10H\u001b[22m\u001b[38;5;0;"]
|
||||||
|
[20.912712, "o", "48;5;5mCustomer →\u001b[37;21HCol \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.021241, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.130594, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.237986, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.347183, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.455154, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.566032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.672926, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.780079, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.888829, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[21.996791, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.105822, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.213368, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.320348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.426688, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.535526, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.643373, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.749869, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.858388, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[22.965471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.075854, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.185921, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.294418, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.402404, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.510325, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.620269, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.732998, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.844083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[23.949977, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.059804, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.167351, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.273888, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.38234, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.490268, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.598348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.707559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.815903, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[24.925818, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.031393, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.140847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.247853, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.356972, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.468231, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.578125, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.685815, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.793907, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[25.906736, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.01559, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.123245, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.231876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.339574, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.446666, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.554584, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.612204, "o", "\u001b[36;48H\u001b[38;5;4;49m [Customer Col] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.721803, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.82881, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[26.937051, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.045557, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.101995, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.209792, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.318377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.426665, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.487816, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.595002, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.703005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.811798, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[27.894092, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.002212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.111067, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.21876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.327108, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.435327, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.544297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.588175, "o", "\u001b[3;2H\u001b[38;5;5;49m [Date_Month = 2025-01] \u001b[39;49m \u001b[4;18H\u001b[1m\u001b[4m\u001b[38;5;3;49m \u001b[4;27H Cos\u001b[4;36H \u001b[22m\u001b[24m\u001b[39;49m \u001b[5;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m Stark Enterprises\u001b[24m Soylent Ltd Wonka Industries Cyberdyne Systems Acme Corp Oceanic Airlines\u001b[22m\u001b[39;49m \u001b[6;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[7;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets North \u001b[3m\u001b[38;5;0;48;5;6m \u001b[22m\u001b[38;5;8;48;5;237m \u001b[38;5;15;48;5;237m 4,340\u001b[38;5;8;"]
|
||||||
|
[28.588511, "o", "48;5;237m \u001b[23m\u001b[39;48;5;237m \u001b[8;12H\u001b[39;49mEast\u001b[8;18H\u001b[3m 5,180\u001b[8;48H 6,230\u001b[8;110H\u001b[23m \u001b[9;12HSou\u001b[9;16Hh\u001b[9;18H\u001b[3m\u001b[38;5;8;49m \u001b[9;39H\u001b[39;49m 3,850\u001b[9;56H\u001b[38;5;8;49m \u001b[9;110H\u001b[23m\u001b[39;49m \u001b[10;12HWest\u001b[10;18H\u001b[3m\u001b[38;5;8;49m \u001b[10;56H \u001b[10;68H\u001b[39;49m 4,690\u001b[10;110H\u001b[23m \u001b[11;2HWidgets\u001b[11;12HNorth\u001b[11;18H\u001b[3m\u001b[38;5;8;49m \u001b[11;83H\u001b[39;49m 7,200\u001b[11;110H\u001b[23m \u001b[12;12HEas\u001b[12;16H \u001b[12;18H\u001b[3m 7,080\u001b[12;44H\u001b[38;5;8;49m \u001b[12;51H\u001b[39;49m 8,520\u001b[12;110H\u001b[23m \u001b[13;12HSouth\u001b[13;18H\u001b[3m\u001b[38;5;8;49m \u001b[13;36H\u001b[39;49m 5,880\u001b[13;110H\u001b[23m \u001b[14;12HWest\u001b[14;18H\u001b[3m\u001b[38;5;8;49m \u001b[14;44H \u001b[14;93H\u001b[39;49m 6,300\u001b[23m \u001b[15;2HSprockets\u001b[15;12HNor\u001b[15;16Hh\u001b[15;18H\u001b[3m\u001b[38;5;8;49m \u001b[15;73H \u001b[15;110H\u001b[23m\u001b[39;49m \u001b[16;12HSouth\u001b[16;18H\u001b[3m\u001b[38;5;8;"]
|
||||||
|
[28.588661, "o", "49m \u001b[16;101H \u001b[23m\u001b[39;49m \u001b[17;12HWest\u001b[17;18H\u001b[3m\u001b[38;5;8;49m \u001b[17;73H \u001b[17;93H\u001b[39;49m 2,240\u001b[23m \u001b[18;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[19;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 9,730 14,750 4,690 11,540 8,540\u001b[22m\u001b[39;49m \u001b[20;18H \u001b[20;26H \u001b[21;12H \u001b[21;18H \u001b[21;26H \u001b[22;18H \u001b[22;26H "]
|
||||||
|
[28.588738, "o", " \u001b[23;18H \u001b[23;26H \u001b[24;12H \u001b[24;18H \u001b[24;26H \u001b[25;18H \u001b[25;26H \u001b[26;18H \u001b[26;26H \u001b[27;12H \u001b[27;18H \u001b[27;26H \u001b[28;18H \u001b[28;26H \u001b[29;18H \u001b[29;26H \u001b[30;2H \u001b[30;12H \u001b[30;18H \u001b[30;26H \u001b[31;18H \u001b[31;26H "]
|
||||||
|
[28.588853, "o", " \u001b[32;12H \u001b[32;18H \u001b[32;26H \u001b[33;18H \u001b[33;26H \u001b[34;12H \u001b[34;18H \u001b[34;26H \u001b[36;116H\u001b[1m\u001b[38;5;0;48;5;6mPag\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month\u001b[37;21H→ Page\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.691453, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.796795, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[28.902199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.006987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.111806, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.215479, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.319908, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.423309, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.528042, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.632673, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.737576, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.841402, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[29.945086, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.048563, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.152632, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.257558, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.361587, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.466201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.569187, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.673297, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.776706, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.880876, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[30.984693, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.088614, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.192586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.29718, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.325158, "o", "\u001b[36;89H\u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[22m\u001b[38;5;5;49m [Date_Month Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.43008, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.533714, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.640572, "o", "\u001b[36;74H\u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[22m\u001b[38;5;2;49m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.745475, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.848524, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[31.930201, "o", "\u001b[36;64H\u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[22m\u001b[38;5;2;49m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.033502, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.136248, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.239752, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.342356, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.429596, "o", "\u001b[36;48H\u001b[1m\u001b[38;5;0;48;5;6m [Customer Col] \u001b[22m\u001b[38;5;8;49m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.532883, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.635586, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.738898, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.84424, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.947995, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[32.984771, "o", "\u001b[3;4H\u001b[38;5;5;49mCustomer =\u001b[3;15HStark Enterprises | Date_Month = 2025-01] \u001b[4;15H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49mWidgets East \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[8;2H\u001b[38;5;8;49m──────────────────────"]
|
||||||
|
[32.984836, "o", "────────────────────────────────────────────────────────────────────────────────────────────────\u001b[9;2H\u001b[1m\u001b[38;5;3;49mTotal 12,260 19,200 6,940\u001b[22m\u001b[39;49m \u001b[10;12H \u001b[10;18H \u001b[11;2H \u001b[11;12H \u001b[11;18H \u001b[12;12H \u001b[12;18H \u001b[13;12H \u001b[13;18H \u001b[14;12H \u001b[14;18H \u001b[15;2H \u001b"]
|
||||||
|
[32.984988, "o", "[15;12H \u001b[15;18H \u001b[16;12H \u001b[16;18H \u001b[17;12H \u001b[17;18H \u001b[18;2H \u001b[19;2H \u001b[36;59H\u001b[1m\u001b[38;5;0;48;5;6mPag\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mCustomer →\u001b[37;21HPage \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.086487, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.18779, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.289838, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.391186, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.492681, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.593254, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.69439, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.795177, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.896392, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[33.998164, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.103769, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.204562, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.305805, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.407428, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.50897, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.610366, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.71215, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.813785, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[34.915156, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.017084, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.118645, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.220083, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.321694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.414691, "o", "\u001b[36;48H\u001b[38;5;5;49m [Customer Pag] \u001b[1m\u001b[38;5;0;48;5;6m [Date ·] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.515987, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.617711, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.719335, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.820861, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[35.922469, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.02437, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.126192, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.14992, "o", "\u001b[36;64H\u001b[38;5;8;49m [Date ·] \u001b[1m\u001b[38;5;0;48;5;6m [Product Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.251377, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.352991, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.455271, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.556932, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.658353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.759434, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.860885, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[36.961396, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.063528, "o", "\u001b[36;74H\u001b[38;5;2;49m [Product Row] \u001b[1m\u001b[38;5;0;48;5;6m [Region Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.165156, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.2662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.367788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.469285, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.571052, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.672135, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.774993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.876832, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[37.978463, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.079993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.181443, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.282509, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.38423, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.485301, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.586649, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.689199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.791226, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.892952, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[38.993685, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.095155, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.19662, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.298101, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.310417, "o", "\u001b[36;89H\u001b[38;5;2;49m [Region Row] \u001b[1m\u001b[38;5;0;48;5;6m [Date_Month Pag] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.41267, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.514667, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.617075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.718774, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.820583, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[39.922075, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.023689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.125316, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.226916, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.230596, "o", "\u001b[3;32H\u001b[38;5;5;49m] \u001b[39;49m \u001b[4;15H \u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue Profit\u001b[6;10H\u001b[38;5;6;48;5;237m East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[7;2H\u001b[23m\u001b[39;49m \u001b[7;10H \u001b[7;15H 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[8;2H\u001b[23mWidgets East 2025-01 \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[9;2H 2025-03 \u001b[3m 7,500 12,500 5,000\u001b[10;2H\u001b[23mSprockets\u001b[10;12HEast\u001b[10;17H2025-02\u001b[10;25H\u001b[3m 2,940 4,200 1,260\u001b[11;2H\u001b[23m\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[12;2H\u001b[1m\u001b[38;5;3;49mTotal 28,370 44,000 15,630\u001b"]
|
||||||
|
[40.230708, "o", "[36;116H\u001b[38;5;0;48;5;6mRow\u001b[37;10H\u001b[22m\u001b[38;5;0;48;5;5mDate_Month\u001b[37;21H→ Row\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.332315, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.433228, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.535032, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.636694, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.737953, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.838891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[40.940545, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.042589, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.144286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.246074, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.347233, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.448212, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.549993, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.651976, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.753813, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.854633, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[41.957087, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.059085, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.16124, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.262945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.365201, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.467407, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.482247, "o", "\u001b[36;103H\u001b[38;5;2;49m [Date_Month Row] \u001b[37;1H\u001b[38;5;0;48;5;8m NORMAL Date_Month → Row Default \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.583904, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.685078, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.78682, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.888734, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[42.98978, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.091781, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.193421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.294857, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.395621, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.498973, "o", "\u001b[4;10H\u001b[1m\u001b[4m\u001b[38;5;3;49m Gadgets \u001b[4;26H\u001b[24m Widgets \u001b[4;41H Sprockets\u001b[5;2H\u001b[22m\u001b[39;49m \u001b[1m\u001b[4m\u001b[38;5;3;49m East \u001b[24m East East\u001b[22m\u001b[39;49m \u001b[6;2H \u001b[1m\u001b[4m\u001b[38;5;3;49m 2025-01\u001b[24m 2025-03 2025-01 2025-03 2025-02\u001b[22m\u001b[39;49m \u001b[7;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[8;2H\u001b[1m\u001b[38;5;6;48;5;237mCost \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 5,670 7,080 7,500 2,940\u001b[23m\u001b[39;48;5;237m \u001b[9;2H\u001b[39;49mRevenu"]
|
||||||
|
[43.499016, "o", "e\u001b[9;10H\u001b[3m 7,400 8,100\u001b[9;27H 11,800 12,500 4,200\u001b[10;2H\u001b[23mProfit \u001b[3m 2,220 2,430\u001b[10;27H 4,720\u001b[10;35H 5,\u001b[10;40H00 1,260\u001b[12;12H\u001b[23m\u001b[1m\u001b[38;5;3;49m14,800\u001b[12;20H16,200 2\u001b[12;30H,600 25,\u001b[12;40H00 8,400\u001b[36;32H\u001b[22m\u001b[38;5;2;49m [_Measure Row] \u001b[36;74H\u001b[38;5;4;49m [Product Col] [Region Col] [Date_Month Col] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.600963, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.703421, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.804551, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[43.907097, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.008979, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.110755, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.212471, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.314251, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.415568, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.517273, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.618742, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.705815, "o", "\u001b[4;10H \u001b[4;26H\u001b[1m\u001b[4m\u001b[38;5;3;49m Cost\u001b[24m Revenue\u001b[4;41HProfit\u001b[22m\u001b[39;49m \u001b[5;2H\u001b[38;5;8;49m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[6;2H\u001b[1m\u001b[38;5;6;48;5;237mGadgets East 2025-01 \u001b[3m\u001b[38;5;0;48;5;6m 5,180\u001b[22m\u001b[38;5;15;48;5;237m 7,400 2,220\u001b[23m\u001b[39;48;5;237m \u001b[7;2H\u001b[39;49m 2025-03 \u001b[3m 5,670 8,100 2,430\u001b[23m \u001b[8;2HWidgets East 2025-01 \u001b[3m 7,080 11,800 4,720\u001b[23m \u001b[9;2H \u001b[9;10H 2025-03 \u001b[3m \u001b[9;27H7,500 12,500 5,000\u001b[2"]
|
||||||
|
[44.705935, "o", "3m \u001b[10;2HSprockets East 2025-02 \u001b[3m \u001b[10;27H2,940 \u001b[10;35H4,20\u001b[10;40H 1,260\u001b[23m \u001b[12;12H\u001b[1m\u001b[38;5;3;49m \u001b[12;20H 28,\u001b[12;30H70 44,00\u001b[12;40H 15,630\u001b[22m\u001b[39;49m \u001b[36;32H\u001b[38;5;4;49m [_Measure Col] \u001b[36;74H\u001b[38;5;2;49m [Product Row] [Region Row] [Date_Month Row] \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.807627, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[44.90931, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.010483, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.11209, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.214054, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.315824, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.417061, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.518689, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.62005, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.72157, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.82307, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[45.924911, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.025947, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.128324, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.229411, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.331387, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.432521, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.534601, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.635595, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.737399, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.838296, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[46.939905, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.041873, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.143353, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.245237, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.346678, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.448408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.550153, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.651708, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.753436, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.854847, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[47.956529, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.05797, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.15988, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.261094, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.362924, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.464278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.566104, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.667592, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.769687, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.871205, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[48.972328, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.07408, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.175891, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.277195, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.3788, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.442395, "o", "\u001b[37;1H\u001b[1m\u001b[38;5;3;48;5;235m:▌ \u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.544319, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.645499, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.747199, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.848828, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.863148, "o", "\u001b[37;2H\u001b[1m\u001b[38;5;3;48;5;235mq▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[49.964715, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.066286, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.167945, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.269492, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.350163, "o", "\u001b[37;3H\u001b[1m\u001b[38;5;3;48;5;235m!▌\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.452616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.554188, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.655924, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.757431, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.859183, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[50.960929, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.062657, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.163616, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.265278, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.367394, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.468649, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.570276, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.671308, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.773372, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.874565, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[51.97578, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.07771, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.179348, "o", "\u001b[39m\u001b[49m\u001b[59m\u001b[0m\u001b[?25l"]
|
||||||
|
[52.18419, "o", "\u001b[?1049l"]
|
||||||
|
[52.18437, "o", "\u001b[?25h"]
|
||||||
|
[52.186019, "o", "\u001b[1m\u001b[3m%\u001b[23m\u001b[1m\u001b[0m \r \r"]
|
||||||
|
[52.233127, "o", "\u001b]0;~/git_repos/git.fiddlerwoaroof.com/u/edwlan/improvise\u0007"]
|
||||||
|
[52.387142, "o", "\r\u001b[0m\u001b[23m\u001b[24m\u001b[J---\r\n(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise \u001b[30m\u001b[35m\u001b[39mgit\u001b[35m\u001b[33m->\u001b[35m\u001b[32mmain\u001b[35m\u001b[39m\u001b[00m 2026-04-09 15:03:40\r\n16024:% \u001b[K"]
|
||||||
|
[52.387248, "o", "\u001b[?2004h"]
|
||||||
|
[53.608892, "o", "\u001b[?2004l\r\r\n"]
|
||||||
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 773 KiB |
105
docs/demo.tape
Normal file
105
docs/demo.tape
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# improvise demo — formulas, command mode, drill, records, and axis reassignment
|
||||||
|
# Run: nix develop --command vhs docs/demo.tape
|
||||||
|
|
||||||
|
Output docs/demo.gif
|
||||||
|
|
||||||
|
Set FontSize 14
|
||||||
|
Set Width 1440
|
||||||
|
Set Height 800
|
||||||
|
Set Theme "Dracula"
|
||||||
|
|
||||||
|
# Hide the shell prompt and startup
|
||||||
|
Hide
|
||||||
|
Type "./result/bin/improvise examples/demo.improv"
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
Show
|
||||||
|
|
||||||
|
# Show the initial pivot view
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Prune empty rows for a cleaner view
|
||||||
|
Type "P"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Open formula panel — show existing Profit formula
|
||||||
|
Type "F"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Add a Margin formula
|
||||||
|
Type "n"
|
||||||
|
Sleep 500ms
|
||||||
|
Type "Margin = 100 * Profit / Revenue"
|
||||||
|
Sleep 1s
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Close formula panel
|
||||||
|
Escape
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Use command mode to hide Date_Month (collapse time dimension)
|
||||||
|
Type ":"
|
||||||
|
Sleep 800ms
|
||||||
|
Type "set-axis Date_Month none"
|
||||||
|
Sleep 1500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Drill into a Revenue cell to see individual records
|
||||||
|
# Navigate down to a row with data
|
||||||
|
Type "jjj"
|
||||||
|
Sleep 500ms
|
||||||
|
# Drill
|
||||||
|
Type ">"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Browse the drill records
|
||||||
|
Type "jj"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Go back
|
||||||
|
Type "<"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Toggle records mode to see flat record view
|
||||||
|
Type "R"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Scroll through records
|
||||||
|
Type "jjjjj"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Back to pivot mode
|
||||||
|
Type "R"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Enter tile mode to reassign axes
|
||||||
|
Type "T"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Navigate to Region tile
|
||||||
|
Type "lllll"
|
||||||
|
Sleep 800ms
|
||||||
|
|
||||||
|
# Move Region from Row to Column
|
||||||
|
Type "c"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Navigate back to Customer tile
|
||||||
|
Type "hhh"
|
||||||
|
Sleep 600ms
|
||||||
|
|
||||||
|
# Move Customer from Page to Row
|
||||||
|
Type "r"
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Exit tile mode
|
||||||
|
Escape
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Quit without recording the exit
|
||||||
|
Hide
|
||||||
|
Type ":q!"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
28
docs/design-notes.md
Normal file
28
docs/design-notes.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Design Notes
|
||||||
|
|
||||||
|
> **Staleness warning:** This document captures conceptual framing from the
|
||||||
|
> original specification. It is not kept in sync with the code. For current
|
||||||
|
> architecture and types, see `context/repo-map.md` and
|
||||||
|
> `context/design-principles.md`.
|
||||||
|
|
||||||
|
## Product Vision
|
||||||
|
|
||||||
|
Traditional spreadsheets conflate data, formulas, and presentation into a single
|
||||||
|
flat grid addressed by opaque cell references (A1, B7). This makes models
|
||||||
|
fragile, hard to audit, and impossible to rearrange without rewriting formulas.
|
||||||
|
|
||||||
|
Improvise treats data as a multi-dimensional, semantically labeled structure --
|
||||||
|
separating data, computation, and views into independent layers. Formulas
|
||||||
|
reference meaningful names, views can be rearranged instantly, and the same
|
||||||
|
dataset can be explored from multiple perspectives simultaneously.
|
||||||
|
|
||||||
|
The application compiles to a single static binary and provides a rich TUI
|
||||||
|
experience.
|
||||||
|
|
||||||
|
## Non-Goals (v1)
|
||||||
|
|
||||||
|
- Scripting/macro language beyond the formula system.
|
||||||
|
- Collaborative/multi-user editing.
|
||||||
|
- Live external data sources (databases, APIs).
|
||||||
|
- Charts or graphical visualization.
|
||||||
|
- Multi-level undo history.
|
||||||
41
examples/demo.csv
Normal file
41
examples/demo.csv
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
Date,Region,Product,Customer,Revenue,Cost
|
||||||
|
2025-01-15,North,Widgets,Acme Corp,12000,7200
|
||||||
|
2025-01-22,North,Widgets,Globex Inc,8500,5100
|
||||||
|
2025-01-08,North,Gadgets,Acme Corp,6200,4340
|
||||||
|
2025-01-30,North,Gadgets,Initech,4100,2870
|
||||||
|
2025-01-12,North,Sprockets,Globex Inc,3400,2380
|
||||||
|
2025-02-05,North,Widgets,Initech,11000,6600
|
||||||
|
2025-02-18,North,Gadgets,Acme Corp,7300,5110
|
||||||
|
2025-02-25,North,Sprockets,Globex Inc,2900,2030
|
||||||
|
2025-03-10,North,Widgets,Acme Corp,13500,8100
|
||||||
|
2025-03-19,North,Gadgets,Initech,5800,4060
|
||||||
|
2025-01-09,South,Widgets,Soylent Ltd,9800,5880
|
||||||
|
2025-01-20,South,Widgets,Umbrella Co,7200,4320
|
||||||
|
2025-01-14,South,Gadgets,Soylent Ltd,5500,3850
|
||||||
|
2025-01-28,South,Sprockets,Umbrella Co,2800,1960
|
||||||
|
2025-02-03,South,Widgets,Soylent Ltd,10200,6120
|
||||||
|
2025-02-15,South,Gadgets,Umbrella Co,6100,4270
|
||||||
|
2025-02-22,South,Sprockets,Soylent Ltd,3100,2170
|
||||||
|
2025-03-07,South,Widgets,Umbrella Co,8900,5340
|
||||||
|
2025-03-18,South,Gadgets,Soylent Ltd,6800,4760
|
||||||
|
2025-03-28,South,Sprockets,Umbrella Co,3500,2450
|
||||||
|
2025-01-06,East,Widgets,Wonka Industries,14200,8520
|
||||||
|
2025-01-17,East,Widgets,Stark Enterprises,11800,7080
|
||||||
|
2025-01-23,East,Gadgets,Wonka Industries,8900,6230
|
||||||
|
2025-01-31,East,Gadgets,Stark Enterprises,7400,5180
|
||||||
|
2025-02-10,East,Widgets,Wonka Industries,15000,9000
|
||||||
|
2025-02-20,East,Sprockets,Stark Enterprises,4200,2940
|
||||||
|
2025-02-28,East,Gadgets,Wonka Industries,9200,6440
|
||||||
|
2025-03-05,East,Widgets,Stark Enterprises,12500,7500
|
||||||
|
2025-03-14,East,Sprockets,Wonka Industries,4800,3360
|
||||||
|
2025-03-25,East,Gadgets,Stark Enterprises,8100,5670
|
||||||
|
2025-01-11,West,Widgets,Oceanic Airlines,10500,6300
|
||||||
|
2025-01-19,West,Gadgets,Cyberdyne Systems,6700,4690
|
||||||
|
2025-01-27,West,Sprockets,Oceanic Airlines,3200,2240
|
||||||
|
2025-02-06,West,Widgets,Cyberdyne Systems,11200,6720
|
||||||
|
2025-02-14,West,Gadgets,Oceanic Airlines,7100,4970
|
||||||
|
2025-02-24,West,Sprockets,Cyberdyne Systems,3600,2520
|
||||||
|
2025-03-03,West,Widgets,Oceanic Airlines,12800,7680
|
||||||
|
2025-03-12,West,Gadgets,Cyberdyne Systems,7500,5250
|
||||||
|
2025-03-21,West,Sprockets,Oceanic Airlines,4000,2800
|
||||||
|
2025-03-30,West,Widgets,Cyberdyne Systems,9800,5880
|
||||||
|
117
examples/demo.improv
Normal file
117
examples/demo.improv
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
v2025-04-09
|
||||||
|
# Acme Sales Demo
|
||||||
|
Initial View: Default
|
||||||
|
|
||||||
|
## View: Default
|
||||||
|
_Index: none
|
||||||
|
_Dim: none
|
||||||
|
_Measure: column
|
||||||
|
Customer: page
|
||||||
|
Date: none
|
||||||
|
Product: row
|
||||||
|
Region: row
|
||||||
|
Date_Month: column
|
||||||
|
format: ,.0
|
||||||
|
|
||||||
|
## Formulas
|
||||||
|
- Profit = Revenue - Cost
|
||||||
|
|
||||||
|
## Category: _Measure
|
||||||
|
- Cost, Revenue
|
||||||
|
|
||||||
|
## Category: Customer
|
||||||
|
- |Stark Enterprises|, |Soylent Ltd|, |Wonka Industries|, |Cyberdyne Systems|, |Acme Corp|, |Oceanic Airlines|, |Umbrella Co|, |Globex Inc|, Initech
|
||||||
|
|
||||||
|
## Category: Date
|
||||||
|
- |2025-01-23|, |2025-03-25|, |2025-01-11|, |2025-03-12|, |2025-01-06|, |2025-01-15|, |2025-03-30|, |2025-03-10|, |2025-03-05|, |2025-01-19|, |2025-03-28|, |2025-01-22|, |2025-01-30|, |2025-01-12|, |2025-02-15|, |2025-03-07|, |2025-01-31|, |2025-01-27|, |2025-01-28|, |2025-03-03|, |2025-03-19|, |2025-02-05|, |2025-02-22|, |2025-02-25|, |2025-03-18|, |2025-02-20|, |2025-02-24|, |2025-01-14|, |2025-03-21|, |2025-01-08|, |2025-02-03|, |2025-02-10|, |2025-03-14|, |2025-02-14|, |2025-02-06|, |2025-01-09|, |2025-01-17|, |2025-01-20|, |2025-02-18|, |2025-02-28|
|
||||||
|
|
||||||
|
## Category: Product
|
||||||
|
- Gadgets, Widgets, Sprockets
|
||||||
|
|
||||||
|
## Category: Region
|
||||||
|
- North, East, South, West
|
||||||
|
|
||||||
|
## Category: Date_Month
|
||||||
|
- |2025-01|, |2025-02|, |2025-03|
|
||||||
|
|
||||||
|
## Data
|
||||||
|
Customer=Initech, Date=|2025-01-30|, Date_Month=|2025-01|, Product=Gadgets, Region=North, _Measure=Cost = 2870
|
||||||
|
Customer=Initech, Date=|2025-01-30|, Date_Month=|2025-01|, Product=Gadgets, Region=North, _Measure=Revenue = 4100
|
||||||
|
Customer=Initech, Date=|2025-02-05|, Date_Month=|2025-02|, Product=Widgets, Region=North, _Measure=Cost = 6600
|
||||||
|
Customer=Initech, Date=|2025-02-05|, Date_Month=|2025-02|, Product=Widgets, Region=North, _Measure=Revenue = 11000
|
||||||
|
Customer=Initech, Date=|2025-03-19|, Date_Month=|2025-03|, Product=Gadgets, Region=North, _Measure=Cost = 4060
|
||||||
|
Customer=Initech, Date=|2025-03-19|, Date_Month=|2025-03|, Product=Gadgets, Region=North, _Measure=Revenue = 5800
|
||||||
|
Customer=|Acme Corp|, Date=|2025-01-08|, Date_Month=|2025-01|, Product=Gadgets, Region=North, _Measure=Cost = 4340
|
||||||
|
Customer=|Acme Corp|, Date=|2025-01-08|, Date_Month=|2025-01|, Product=Gadgets, Region=North, _Measure=Revenue = 6200
|
||||||
|
Customer=|Acme Corp|, Date=|2025-01-15|, Date_Month=|2025-01|, Product=Widgets, Region=North, _Measure=Cost = 7200
|
||||||
|
Customer=|Acme Corp|, Date=|2025-01-15|, Date_Month=|2025-01|, Product=Widgets, Region=North, _Measure=Revenue = 12000
|
||||||
|
Customer=|Acme Corp|, Date=|2025-02-18|, Date_Month=|2025-02|, Product=Gadgets, Region=North, _Measure=Cost = 5110
|
||||||
|
Customer=|Acme Corp|, Date=|2025-02-18|, Date_Month=|2025-02|, Product=Gadgets, Region=North, _Measure=Revenue = 7300
|
||||||
|
Customer=|Acme Corp|, Date=|2025-03-10|, Date_Month=|2025-03|, Product=Widgets, Region=North, _Measure=Cost = 8100
|
||||||
|
Customer=|Acme Corp|, Date=|2025-03-10|, Date_Month=|2025-03|, Product=Widgets, Region=North, _Measure=Revenue = 13500
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-01-19|, Date_Month=|2025-01|, Product=Gadgets, Region=West, _Measure=Cost = 4690
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-01-19|, Date_Month=|2025-01|, Product=Gadgets, Region=West, _Measure=Revenue = 6700
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-02-06|, Date_Month=|2025-02|, Product=Widgets, Region=West, _Measure=Cost = 6720
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-02-06|, Date_Month=|2025-02|, Product=Widgets, Region=West, _Measure=Revenue = 11200
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-02-24|, Date_Month=|2025-02|, Product=Sprockets, Region=West, _Measure=Cost = 2520
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-02-24|, Date_Month=|2025-02|, Product=Sprockets, Region=West, _Measure=Revenue = 3600
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-03-12|, Date_Month=|2025-03|, Product=Gadgets, Region=West, _Measure=Cost = 5250
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-03-12|, Date_Month=|2025-03|, Product=Gadgets, Region=West, _Measure=Revenue = 7500
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-03-30|, Date_Month=|2025-03|, Product=Widgets, Region=West, _Measure=Cost = 5880
|
||||||
|
Customer=|Cyberdyne Systems|, Date=|2025-03-30|, Date_Month=|2025-03|, Product=Widgets, Region=West, _Measure=Revenue = 9800
|
||||||
|
Customer=|Globex Inc|, Date=|2025-01-12|, Date_Month=|2025-01|, Product=Sprockets, Region=North, _Measure=Cost = 2380
|
||||||
|
Customer=|Globex Inc|, Date=|2025-01-12|, Date_Month=|2025-01|, Product=Sprockets, Region=North, _Measure=Revenue = 3400
|
||||||
|
Customer=|Globex Inc|, Date=|2025-01-22|, Date_Month=|2025-01|, Product=Widgets, Region=North, _Measure=Cost = 5100
|
||||||
|
Customer=|Globex Inc|, Date=|2025-01-22|, Date_Month=|2025-01|, Product=Widgets, Region=North, _Measure=Revenue = 8500
|
||||||
|
Customer=|Globex Inc|, Date=|2025-02-25|, Date_Month=|2025-02|, Product=Sprockets, Region=North, _Measure=Cost = 2030
|
||||||
|
Customer=|Globex Inc|, Date=|2025-02-25|, Date_Month=|2025-02|, Product=Sprockets, Region=North, _Measure=Revenue = 2900
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-01-11|, Date_Month=|2025-01|, Product=Widgets, Region=West, _Measure=Cost = 6300
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-01-11|, Date_Month=|2025-01|, Product=Widgets, Region=West, _Measure=Revenue = 10500
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-01-27|, Date_Month=|2025-01|, Product=Sprockets, Region=West, _Measure=Cost = 2240
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-01-27|, Date_Month=|2025-01|, Product=Sprockets, Region=West, _Measure=Revenue = 3200
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-02-14|, Date_Month=|2025-02|, Product=Gadgets, Region=West, _Measure=Cost = 4970
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-02-14|, Date_Month=|2025-02|, Product=Gadgets, Region=West, _Measure=Revenue = 7100
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-03-03|, Date_Month=|2025-03|, Product=Widgets, Region=West, _Measure=Cost = 7680
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-03-03|, Date_Month=|2025-03|, Product=Widgets, Region=West, _Measure=Revenue = 12800
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-03-21|, Date_Month=|2025-03|, Product=Sprockets, Region=West, _Measure=Cost = 2800
|
||||||
|
Customer=|Oceanic Airlines|, Date=|2025-03-21|, Date_Month=|2025-03|, Product=Sprockets, Region=West, _Measure=Revenue = 4000
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-01-09|, Date_Month=|2025-01|, Product=Widgets, Region=South, _Measure=Cost = 5880
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-01-09|, Date_Month=|2025-01|, Product=Widgets, Region=South, _Measure=Revenue = 9800
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-01-14|, Date_Month=|2025-01|, Product=Gadgets, Region=South, _Measure=Cost = 3850
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-01-14|, Date_Month=|2025-01|, Product=Gadgets, Region=South, _Measure=Revenue = 5500
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-02-03|, Date_Month=|2025-02|, Product=Widgets, Region=South, _Measure=Cost = 6120
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-02-03|, Date_Month=|2025-02|, Product=Widgets, Region=South, _Measure=Revenue = 10200
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-02-22|, Date_Month=|2025-02|, Product=Sprockets, Region=South, _Measure=Cost = 2170
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-02-22|, Date_Month=|2025-02|, Product=Sprockets, Region=South, _Measure=Revenue = 3100
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-03-18|, Date_Month=|2025-03|, Product=Gadgets, Region=South, _Measure=Cost = 4760
|
||||||
|
Customer=|Soylent Ltd|, Date=|2025-03-18|, Date_Month=|2025-03|, Product=Gadgets, Region=South, _Measure=Revenue = 6800
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-01-17|, Date_Month=|2025-01|, Product=Widgets, Region=East, _Measure=Cost = 7080
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-01-17|, Date_Month=|2025-01|, Product=Widgets, Region=East, _Measure=Revenue = 11800
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-01-31|, Date_Month=|2025-01|, Product=Gadgets, Region=East, _Measure=Cost = 5180
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-01-31|, Date_Month=|2025-01|, Product=Gadgets, Region=East, _Measure=Revenue = 7400
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-02-20|, Date_Month=|2025-02|, Product=Sprockets, Region=East, _Measure=Cost = 2940
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-02-20|, Date_Month=|2025-02|, Product=Sprockets, Region=East, _Measure=Revenue = 4200
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-03-05|, Date_Month=|2025-03|, Product=Widgets, Region=East, _Measure=Cost = 7500
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-03-05|, Date_Month=|2025-03|, Product=Widgets, Region=East, _Measure=Revenue = 12500
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-03-25|, Date_Month=|2025-03|, Product=Gadgets, Region=East, _Measure=Cost = 5670
|
||||||
|
Customer=|Stark Enterprises|, Date=|2025-03-25|, Date_Month=|2025-03|, Product=Gadgets, Region=East, _Measure=Revenue = 8100
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-01-20|, Date_Month=|2025-01|, Product=Widgets, Region=South, _Measure=Cost = 4320
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-01-20|, Date_Month=|2025-01|, Product=Widgets, Region=South, _Measure=Revenue = 7200
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-01-28|, Date_Month=|2025-01|, Product=Sprockets, Region=South, _Measure=Cost = 1960
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-01-28|, Date_Month=|2025-01|, Product=Sprockets, Region=South, _Measure=Revenue = 2800
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-02-15|, Date_Month=|2025-02|, Product=Gadgets, Region=South, _Measure=Cost = 4270
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-02-15|, Date_Month=|2025-02|, Product=Gadgets, Region=South, _Measure=Revenue = 6100
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-03-07|, Date_Month=|2025-03|, Product=Widgets, Region=South, _Measure=Cost = 5340
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-03-07|, Date_Month=|2025-03|, Product=Widgets, Region=South, _Measure=Revenue = 8900
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-03-28|, Date_Month=|2025-03|, Product=Sprockets, Region=South, _Measure=Cost = 2450
|
||||||
|
Customer=|Umbrella Co|, Date=|2025-03-28|, Date_Month=|2025-03|, Product=Sprockets, Region=South, _Measure=Revenue = 3500
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-01-06|, Date_Month=|2025-01|, Product=Widgets, Region=East, _Measure=Cost = 8520
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-01-06|, Date_Month=|2025-01|, Product=Widgets, Region=East, _Measure=Revenue = 14200
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-01-23|, Date_Month=|2025-01|, Product=Gadgets, Region=East, _Measure=Cost = 6230
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-01-23|, Date_Month=|2025-01|, Product=Gadgets, Region=East, _Measure=Revenue = 8900
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-02-10|, Date_Month=|2025-02|, Product=Widgets, Region=East, _Measure=Cost = 9000
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-02-10|, Date_Month=|2025-02|, Product=Widgets, Region=East, _Measure=Revenue = 15000
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-02-28|, Date_Month=|2025-02|, Product=Gadgets, Region=East, _Measure=Cost = 6440
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-02-28|, Date_Month=|2025-02|, Product=Gadgets, Region=East, _Measure=Revenue = 9200
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-03-14|, Date_Month=|2025-03|, Product=Sprockets, Region=East, _Measure=Cost = 3360
|
||||||
|
Customer=|Wonka Industries|, Date=|2025-03-14|, Date_Month=|2025-03|, Product=Sprockets, Region=East, _Measure=Revenue = 4800
|
||||||
344
examples/gen-grammar.rs
Normal file
344
examples/gen-grammar.rs
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
//! Generate a random valid example matching a rule from `improv.pest`.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example gen-grammar -- <rule_name>
|
||||||
|
//!
|
||||||
|
//! Examples:
|
||||||
|
//! cargo run --example gen-grammar -- file
|
||||||
|
//! cargo run --example gen-grammar -- category_section
|
||||||
|
//! cargo run --example gen-grammar -- bare_name
|
||||||
|
//!
|
||||||
|
//! Each invocation generates one example seeded from the current time + PID.
|
||||||
|
//!
|
||||||
|
//! The generator adds constraints beyond what the grammar requires to produce
|
||||||
|
//! realistic, round-trippable output:
|
||||||
|
//! - bare names are drawn from a word pool instead of random letters
|
||||||
|
//! - pipe_inner is never empty
|
||||||
|
//! - rest_of_line always produces at least one character
|
||||||
|
//! - repetitions (`*`) produce 1–4 items, not 0
|
||||||
|
|
||||||
|
use pest_meta::ast::{Expr, RuleType};
|
||||||
|
use pest_meta::parser;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const GRAMMAR: &str =
|
||||||
|
include_str!("../crates/improvise-io/src/persistence/improv.pest");
|
||||||
|
|
||||||
|
fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
|
||||||
|
let pairs = parser::parse(parser::Rule::grammar_rules, GRAMMAR)
|
||||||
|
.unwrap_or_else(|e| panic!("Bad grammar: {e}"));
|
||||||
|
let rules = parser::consume_rules(pairs).unwrap_or_else(|e| panic!("{e:?}"));
|
||||||
|
rules
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.name.clone(), (r.ty, r.expr)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Word pools for realistic output ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const BARE_WORDS: &[&str] = &[
|
||||||
|
"Region",
|
||||||
|
"Product",
|
||||||
|
"Customer",
|
||||||
|
"Channel",
|
||||||
|
"Date",
|
||||||
|
"North",
|
||||||
|
"South",
|
||||||
|
"East",
|
||||||
|
"West",
|
||||||
|
"Revenue",
|
||||||
|
"Cost",
|
||||||
|
"Profit",
|
||||||
|
"Margin",
|
||||||
|
"Widgets",
|
||||||
|
"Gadgets",
|
||||||
|
"Sprockets",
|
||||||
|
"Q1",
|
||||||
|
"Q2",
|
||||||
|
"Q3",
|
||||||
|
"Q4",
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"Acme",
|
||||||
|
"Globex",
|
||||||
|
"Initech",
|
||||||
|
"Umbrella",
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUOTED_WORDS: &[&str] = &[
|
||||||
|
"Total Revenue",
|
||||||
|
"Net Income",
|
||||||
|
"Gross Margin",
|
||||||
|
"2025-01",
|
||||||
|
"2025-02",
|
||||||
|
"2025-03",
|
||||||
|
"East Coast",
|
||||||
|
"West Coast",
|
||||||
|
"Acme Corp",
|
||||||
|
"Globex Inc",
|
||||||
|
"Cost of Goods",
|
||||||
|
"Operating Expense",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODEL_NAMES: &[&str] = &[
|
||||||
|
"Sales Report",
|
||||||
|
"Budget 2025",
|
||||||
|
"Quarterly Review",
|
||||||
|
"Inventory Model",
|
||||||
|
"Revenue Analysis",
|
||||||
|
"Demo Model",
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIEW_NAMES: &[&str] = &["Default", "Summary", "Detail", "By Region", "Monthly"];
|
||||||
|
|
||||||
|
const FORMULA_EXPRS: &[&str] = &[
|
||||||
|
"Profit = Revenue - Cost",
|
||||||
|
"Margin = Profit / Revenue",
|
||||||
|
"Tax = Revenue * 0.1",
|
||||||
|
"Total = SUM(Revenue)",
|
||||||
|
"Net = Revenue - Cost - Tax",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORMAT_STRINGS: &[&str] = &[",.0", ",.2f", ",.1f", ".0%"];
|
||||||
|
|
||||||
|
// ── PRNG ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Xs64(u64);
|
||||||
|
|
||||||
|
impl Xs64 {
|
||||||
|
fn new(seed: u64) -> Self {
|
||||||
|
Self(seed.max(1))
|
||||||
|
}
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 ^= self.0 << 13;
|
||||||
|
self.0 ^= self.0 >> 7;
|
||||||
|
self.0 ^= self.0 << 17;
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn byte(&mut self) -> u8 {
|
||||||
|
(self.next() & 0xff) as u8
|
||||||
|
}
|
||||||
|
fn pick_from<'a>(&mut self, pool: &[&'a str]) -> &'a str {
|
||||||
|
pool[self.next() as usize % pool.len()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generator ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct Gen<'g> {
|
||||||
|
rules: &'g HashMap<String, (RuleType, Expr)>,
|
||||||
|
rng: Xs64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'g> Gen<'g> {
|
||||||
|
fn new(rules: &'g HashMap<String, (RuleType, Expr)>, seed: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
rules,
|
||||||
|
rng: Xs64::new(seed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick(&mut self) -> u8 {
|
||||||
|
self.rng.byte()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try a rule-specific override. Returns true if handled.
|
||||||
|
fn try_override(&mut self, rule_name: &str, out: &mut String) -> bool {
|
||||||
|
match rule_name {
|
||||||
|
"bare_name" => {
|
||||||
|
out.push_str(self.rng.pick_from(BARE_WORDS));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"pipe_inner" => {
|
||||||
|
// Never empty
|
||||||
|
out.push_str(self.rng.pick_from(QUOTED_WORDS));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"rest_of_line" => {
|
||||||
|
// Context-sensitive: produce something non-empty
|
||||||
|
let word_count = 1 + self.pick() % 3;
|
||||||
|
for i in 0..word_count {
|
||||||
|
if i > 0 {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push_str(self.rng.pick_from(BARE_WORDS));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"model_name" => {
|
||||||
|
out.push_str("# ");
|
||||||
|
out.push_str(self.rng.pick_from(MODEL_NAMES));
|
||||||
|
out.push('\n');
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"format_line" => {
|
||||||
|
out.push_str("format: ");
|
||||||
|
out.push_str(self.rng.pick_from(FORMAT_STRINGS));
|
||||||
|
out.push('\n');
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"formula_line" => {
|
||||||
|
out.push_str("- ");
|
||||||
|
out.push_str(self.rng.pick_from(FORMULA_EXPRS));
|
||||||
|
out.push('\n');
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"number" => {
|
||||||
|
let whole = 1 + self.rng.next() % 99999;
|
||||||
|
if self.pick().is_multiple_of(3) {
|
||||||
|
let frac = self.rng.next() % 100;
|
||||||
|
out.push_str(&format!("{whole}.{frac:02}"));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("{whole}"));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"axis_kind" => {
|
||||||
|
let kinds = ["row", "column", "page", "none"];
|
||||||
|
out.push_str(kinds[self.pick() as usize % kinds.len()]);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"view_section" => {
|
||||||
|
out.push_str("## View: ");
|
||||||
|
out.push_str(self.rng.pick_from(VIEW_NAMES));
|
||||||
|
out.push('\n');
|
||||||
|
// Generate view_entry* from the grammar
|
||||||
|
let count = 1 + self.pick() % 4;
|
||||||
|
if let Some((_ty, expr)) = self.rules.get("view_entry") {
|
||||||
|
let expr = expr.clone();
|
||||||
|
for _ in 0..count {
|
||||||
|
self.emit(&expr, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&mut self, expr: &Expr, out: &mut String) {
|
||||||
|
match expr {
|
||||||
|
Expr::Str(s) => out.push_str(s),
|
||||||
|
Expr::Range(lo, hi) => {
|
||||||
|
let lo = lo.chars().next().unwrap() as u32;
|
||||||
|
let hi = hi.chars().next().unwrap() as u32;
|
||||||
|
let range = hi - lo + 1;
|
||||||
|
let ch = char::from_u32(lo + (self.pick() as u32 % range)).unwrap();
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
Expr::Ident(name) => match name.as_str() {
|
||||||
|
"ANY" => {
|
||||||
|
let ch = (b'a' + self.pick() % 26) as char;
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
"NEWLINE" => out.push('\n'),
|
||||||
|
"SOI" | "EOI" => {}
|
||||||
|
"ASCII_DIGIT" => {
|
||||||
|
let d = (b'0' + self.pick() % 10) as char;
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Try override first, fall back to grammar walk
|
||||||
|
if !self.try_override(name, out)
|
||||||
|
&& let Some((_ty, expr)) = self.rules.get(name)
|
||||||
|
{
|
||||||
|
let expr = expr.clone();
|
||||||
|
self.emit(&expr, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Expr::Seq(a, b) => {
|
||||||
|
self.emit(a, out);
|
||||||
|
self.emit(b, out);
|
||||||
|
}
|
||||||
|
Expr::Choice(a, b) => {
|
||||||
|
let mut alts: Vec<&Expr> = vec![a.as_ref()];
|
||||||
|
let mut cur = b.as_ref();
|
||||||
|
while let Expr::Choice(l, r) = cur {
|
||||||
|
alts.push(l.as_ref());
|
||||||
|
cur = r.as_ref();
|
||||||
|
}
|
||||||
|
alts.push(cur);
|
||||||
|
let idx = self.pick() as usize % alts.len();
|
||||||
|
self.emit(alts[idx], out);
|
||||||
|
}
|
||||||
|
Expr::Opt(inner) => {
|
||||||
|
if !self.pick().is_multiple_of(3) {
|
||||||
|
// ~66% chance of emitting
|
||||||
|
self.emit(inner, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Rep(inner) => {
|
||||||
|
// 1–4 reps (never 0 — avoid degenerate empty output)
|
||||||
|
let count = 1 + self.pick() % 4;
|
||||||
|
for _ in 0..count {
|
||||||
|
self.emit(inner, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::RepOnce(inner) => {
|
||||||
|
let count = 1 + self.pick() % 4;
|
||||||
|
for _ in 0..count {
|
||||||
|
self.emit(inner, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::NegPred(_) | Expr::PosPred(_) => {}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate(&mut self, rule_name: &str) -> Option<String> {
|
||||||
|
// Check override first (for top-level rule invocation)
|
||||||
|
let mut out = String::new();
|
||||||
|
if self.try_override(rule_name, &mut out) {
|
||||||
|
return Some(out);
|
||||||
|
}
|
||||||
|
let (_ty, expr) = self.rules.get(rule_name)?.clone();
|
||||||
|
self.emit(&expr, &mut out);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_rules(rules: &HashMap<String, (RuleType, Expr)>) {
|
||||||
|
let mut names: Vec<_> = rules.keys().collect();
|
||||||
|
names.sort();
|
||||||
|
for name in names {
|
||||||
|
println!(" {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let rules = load_grammar();
|
||||||
|
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: {} <rule_name>", args[0]);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Available rules:");
|
||||||
|
print_rules(&rules);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
let rule = &args[1];
|
||||||
|
|
||||||
|
if !rules.contains_key(rule) {
|
||||||
|
eprintln!("Unknown rule '{rule}'. Available rules:");
|
||||||
|
print_rules(&rules);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seed = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
^ (std::process::id() as u64).wrapping_mul(0x9E3779B97F4A7C15);
|
||||||
|
|
||||||
|
let mut g = Gen::new(&rules, seed);
|
||||||
|
match g.generate(rule) {
|
||||||
|
Some(out) => print!("{out}"),
|
||||||
|
None => {
|
||||||
|
eprintln!("Failed to generate from rule '{rule}'");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
examples/pretty-print.rs
Normal file
25
examples/pretty-print.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//! Parse a `.improv` file from stdin and print the formatted result to stdout.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example pretty-print < file.improv
|
||||||
|
//! cargo run --example gen-grammar -- file | cargo run --example pretty-print
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut input = String::new();
|
||||||
|
if let Err(e) = std::io::stdin().read_to_string(&mut input) {
|
||||||
|
eprintln!("Failed to read stdin: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
match improvise::persistence::parse_md(&input) {
|
||||||
|
Ok(model) => {
|
||||||
|
print!("{}", improvise::persistence::format_md(&model));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Parse error: {e:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
flake.lock
generated
276
flake.lock
generated
@ -1,5 +1,111 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"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": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@ -18,13 +124,102 @@
|
|||||||
"type": "github"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774709303,
|
"lastModified": 1765186076,
|
||||||
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
|
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685",
|
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -36,11 +231,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774794121,
|
"lastModified": 1769433173,
|
||||||
"narHash": "sha256-gih24b728CK8twDNU7VX9vVYK2tLEXvy9gm/GKq2VeE=",
|
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c397ef6af68c018462d786e1b65384abc472a907",
|
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@ -50,23 +245,82 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_3": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775710090,
|
||||||
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1744536153,
|
||||||
|
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"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": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"crate2nix": "crate2nix",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_4"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774926780,
|
"lastModified": 1775877051,
|
||||||
"narHash": "sha256-JMdDYn0F+swYBILlpCeHDbCSyzqkeSGNxZ/Q5J584jM=",
|
"narHash": "sha256-wpSQm2PD/w4uRo2wb8utk0b5hOBkkg/CZ1xICY+qB7M=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "962a0934d0e32f42d1b5e49186f9595f9b178d2d",
|
"rev": "08b4f3633471874c8894632ade1b78d75dbda002",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
76
flake.nix
76
flake.nix
@ -7,6 +7,7 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
};
|
};
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
crate2nix.url = "github:nix-community/crate2nix";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs = {
|
||||||
@ -14,65 +15,46 @@
|
|||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
|
crate2nix,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
overlays = [(import rust-overlay)];
|
overlays = [(import rust-overlay)];
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
};
|
||||||
isLinux = pkgs.lib.hasInfix "linux" system;
|
|
||||||
|
|
||||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = ["rust-src" "clippy" "rustfmt"];
|
extensions = ["rust-src" "clippy" "rustfmt" "llvm-tools-preview"];
|
||||||
targets = pkgs.lib.optionals isLinux ["x86_64-unknown-linux-musl"];
|
};
|
||||||
|
|
||||||
|
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
|
||||||
|
name = "improvise";
|
||||||
|
src = ./.;
|
||||||
|
};
|
||||||
|
|
||||||
|
cargoNix = import generatedCargoNix {
|
||||||
|
pkgs = pkgs;
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
devShells.default = pkgs.mkShell ({
|
devShells.default = pkgs.mkShell {
|
||||||
nativeBuildInputs =
|
nativeBuildInputs = [
|
||||||
[
|
|
||||||
rustToolchain
|
rustToolchain
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.rust-analyzer
|
pkgs.rust-analyzer
|
||||||
]
|
crate2nix.packages.${system}.default
|
||||||
++ pkgs.lib.optionals isLinux [
|
pkgs.cargo-expand
|
||||||
# Provide cc (gcc) for building proc-macro / build-script crates
|
pkgs.cargo-llvm-cov
|
||||||
# that target the host (x86_64-unknown-linux-gnu).
|
|
||||||
pkgs.gcc
|
# Demo recording and release tooling
|
||||||
# musl-gcc wrapper for the static musl target.
|
pkgs.asciinema
|
||||||
pkgs.pkgsMusl.stdenv.cc
|
pkgs.vhs
|
||||||
|
pkgs.cargo-dist
|
||||||
|
# nixpkgs cargo-dist installs as "dist"; alias so `cargo dist` works
|
||||||
|
(pkgs.writeShellScriptBin "cargo-dist" ''exec ${pkgs.cargo-dist}/bin/dist "$@"'')
|
||||||
];
|
];
|
||||||
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
6823
roadmap.org
Normal file
6823
roadmap.org
Normal file
File diff suppressed because it is too large
Load Diff
179
scripts/gen_roadmap.py
Executable file
179
scripts/gen_roadmap.py
Executable file
@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate roadmap.org from bd.
|
||||||
|
|
||||||
|
Inverted-tree layout: each tree is rooted at an issue that nothing else
|
||||||
|
(among the open set) depends on — the "epic" or standalone goal. Its
|
||||||
|
children are the things blocking it, recursively. Diamonds in the DAG
|
||||||
|
are broken into a tree by duplicating shared deps under each parent so
|
||||||
|
emacs `[/]` cookies count whole subtrees.
|
||||||
|
|
||||||
|
Usage: python3 scripts/gen_roadmap.py [output-path]
|
||||||
|
(default output: roadmap.org at the repo root)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def bd_json(*args):
|
||||||
|
"""Run `bd <args>` and parse JSON from stdout."""
|
||||||
|
cmd = ['bd', *args, '--json']
|
||||||
|
res = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(res.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
repo = Path(__file__).resolve().parent.parent
|
||||||
|
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else repo / 'roadmap.org'
|
||||||
|
|
||||||
|
issues = bd_json('list')
|
||||||
|
by_id = {i['id']: i for i in issues}
|
||||||
|
# `bd list --all` includes closed issues — used only to resolve titles
|
||||||
|
# of already-done dep targets so we can label them in :DONE_DEPS:.
|
||||||
|
all_issues = bd_json('list', '--all')
|
||||||
|
all_by_id = {i['id']: i for i in all_issues}
|
||||||
|
|
||||||
|
# --- Build dep graph (open-issue subgraph only) ----------------------
|
||||||
|
# child depends_on parent; in our inverted tree, parent (the goal/epic)
|
||||||
|
# sits ABOVE its deps (children).
|
||||||
|
tree_children = defaultdict(set)
|
||||||
|
tree_parents = defaultdict(set)
|
||||||
|
for i in issues:
|
||||||
|
iid = i['id']
|
||||||
|
for d in (i.get('dependencies') or []):
|
||||||
|
dep = d['depends_on_id']
|
||||||
|
if dep in by_id and iid in by_id:
|
||||||
|
tree_children[iid].add(dep)
|
||||||
|
tree_parents[dep].add(iid)
|
||||||
|
|
||||||
|
def order_key(iid):
|
||||||
|
i = by_id[iid]
|
||||||
|
return (
|
||||||
|
0 if i['status'] == 'in_progress' else 1,
|
||||||
|
i['priority'],
|
||||||
|
iid,
|
||||||
|
)
|
||||||
|
|
||||||
|
roots = sorted(
|
||||||
|
[iid for iid in by_id if not tree_parents.get(iid)],
|
||||||
|
key=order_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Helpers ---------------------------------------------------------
|
||||||
|
status_kw = {
|
||||||
|
'open': 'TODO',
|
||||||
|
'in_progress': 'DOING',
|
||||||
|
'closed': 'DONE',
|
||||||
|
'blocked': 'WAIT',
|
||||||
|
'deferred': 'WAIT',
|
||||||
|
}
|
||||||
|
|
||||||
|
def tag(s):
|
||||||
|
return re.sub(r'[^A-Za-z0-9_@#%]', '_', s)
|
||||||
|
|
||||||
|
def headline_tags(i, kind):
|
||||||
|
tags = [kind, f"P{i['priority']}", tag(i['issue_type'])]
|
||||||
|
if i.get('assignee'):
|
||||||
|
tags.append('@' + tag(i['assignee']))
|
||||||
|
return ':' + ':'.join(tags) + ':'
|
||||||
|
|
||||||
|
def fmt_date(s):
|
||||||
|
return s.replace('T', ' ').rstrip('Z') if s else ''
|
||||||
|
|
||||||
|
def kind_of(iid):
|
||||||
|
return 'epic' if tree_children.get(iid) else 'standalone'
|
||||||
|
|
||||||
|
out = []
|
||||||
|
out.append('#+TITLE: Improvise Roadmap')
|
||||||
|
out.append('#+AUTHOR: Edward Langley')
|
||||||
|
out.append('#+TODO: TODO DOING WAIT | DONE')
|
||||||
|
out.append('#+STARTUP: overview')
|
||||||
|
out.append('#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug')
|
||||||
|
out.append('#+PROPERTY: COOKIE_DATA todo recursive')
|
||||||
|
out.append('')
|
||||||
|
out.append(
|
||||||
|
f'Generated from ~bd list --json~. {len(issues)} open issues '
|
||||||
|
f'organised as {len(roots)} inverted dep-trees: each root is a goal '
|
||||||
|
'that nothing else depends on; its children are the deps blocking '
|
||||||
|
'it. Diamonds in the DAG are duplicated so each tree stands alone.'
|
||||||
|
)
|
||||||
|
out.append('')
|
||||||
|
|
||||||
|
def emit(iid, depth, path):
|
||||||
|
i = by_id[iid]
|
||||||
|
kind = kind_of(iid)
|
||||||
|
kw = status_kw.get(i['status'], 'TODO')
|
||||||
|
title = i['title'].replace('[', '(').replace(']', ')')
|
||||||
|
stars = '*' * depth
|
||||||
|
cookie = ' [/]' if tree_children.get(iid) else ''
|
||||||
|
head = f'{stars} {kw} {title} ({kind}){cookie}'
|
||||||
|
pad = max(1, 95 - len(head))
|
||||||
|
out.append(f'{head}{" " * pad}{headline_tags(i, kind)}')
|
||||||
|
|
||||||
|
indent = ' ' * (depth - 1) + ' '
|
||||||
|
out.append(f'{indent}:PROPERTIES:')
|
||||||
|
out.append(f'{indent}:ID: {iid}')
|
||||||
|
out.append(f'{indent}:TYPE: {i["issue_type"]}')
|
||||||
|
out.append(f'{indent}:PRIORITY: P{i["priority"]}')
|
||||||
|
out.append(f'{indent}:STATUS: {i["status"]}')
|
||||||
|
if i.get('assignee'):
|
||||||
|
out.append(f'{indent}:ASSIGNEE: {i["assignee"]}')
|
||||||
|
if i.get('owner'):
|
||||||
|
out.append(f'{indent}:OWNER: {i["owner"]}')
|
||||||
|
if i.get('created_by'):
|
||||||
|
out.append(f'{indent}:CREATED_BY: {i["created_by"]}')
|
||||||
|
out.append(f'{indent}:CREATED: {fmt_date(i["created_at"])}')
|
||||||
|
out.append(f'{indent}:UPDATED: {fmt_date(i["updated_at"])}')
|
||||||
|
if i.get('comment_count'):
|
||||||
|
out.append(f'{indent}:COMMENTS: {i["comment_count"]}')
|
||||||
|
out.append(f'{indent}:KIND: {kind}')
|
||||||
|
closed_deps = sorted(
|
||||||
|
d['depends_on_id']
|
||||||
|
for d in (i.get('dependencies') or [])
|
||||||
|
if (ref := all_by_id.get(d['depends_on_id'])) and ref['status'] == 'closed'
|
||||||
|
)
|
||||||
|
if closed_deps:
|
||||||
|
out.append(f'{indent}:DONE_DEPS: {", ".join(closed_deps)}')
|
||||||
|
if iid in path:
|
||||||
|
out.append(f'{indent}:CYCLE: yes — descendants pruned at this node')
|
||||||
|
out.append(f'{indent}:END:')
|
||||||
|
|
||||||
|
if iid in path:
|
||||||
|
return # cycle guard
|
||||||
|
|
||||||
|
details_stars = '*' * (depth + 1)
|
||||||
|
section_stars = '*' * (depth + 2)
|
||||||
|
section_indent = ' ' * (depth + 1) + ' '
|
||||||
|
if any(i.get(k) for k in ('description', 'design', 'acceptance_criteria', 'notes')):
|
||||||
|
out.append(f'{details_stars} Details')
|
||||||
|
for label, key in [
|
||||||
|
('Description', 'description'),
|
||||||
|
('Design', 'design'),
|
||||||
|
('Acceptance Criteria', 'acceptance_criteria'),
|
||||||
|
('Notes', 'notes'),
|
||||||
|
]:
|
||||||
|
v = i.get(key)
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
out.append(f'{section_stars} {label}')
|
||||||
|
for line in v.rstrip('\n').splitlines():
|
||||||
|
out.append(f'{section_indent}{line}' if line else '')
|
||||||
|
|
||||||
|
new_path = path | {iid}
|
||||||
|
for c in sorted(tree_children.get(iid, set()), key=order_key):
|
||||||
|
out.append('')
|
||||||
|
emit(c, depth + 1, new_path)
|
||||||
|
|
||||||
|
for r in roots:
|
||||||
|
emit(r, 1, frozenset())
|
||||||
|
out.append('')
|
||||||
|
|
||||||
|
out_path.write_text('\n'.join(out))
|
||||||
|
print(f'Wrote {out_path} ({len(out)} lines, {len(roots)} roots, {len(by_id)} issues)')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
32
scripts/record-demo.sh
Executable file
32
scripts/record-demo.sh
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Record asciinema demo casts at a consistent terminal size.
|
||||||
|
# Usage: ./scripts/record-demo.sh [cast-name]
|
||||||
|
# Without arguments, records all four standard casts.
|
||||||
|
# With an argument, records just that one (import, pivot, drill, formulas).
|
||||||
|
|
||||||
|
CAST_DIR="docs/casts"
|
||||||
|
COLS=120
|
||||||
|
ROWS=37
|
||||||
|
IDLE_CAP=2
|
||||||
|
|
||||||
|
mkdir -p "$CAST_DIR"
|
||||||
|
|
||||||
|
record() {
|
||||||
|
local name="$1"
|
||||||
|
local outfile="$CAST_DIR/${name}.cast"
|
||||||
|
echo "Recording $name → $outfile (${COLS}x${ROWS}, idle cap ${IDLE_CAP}s)"
|
||||||
|
echo "Press Ctrl-D or type 'exit' when done."
|
||||||
|
COLUMNS=$COLS LINES=$ROWS asciinema rec -i "$IDLE_CAP" --cols "$COLS" --rows "$ROWS" "$outfile"
|
||||||
|
echo "Saved $outfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
record "$1"
|
||||||
|
else
|
||||||
|
for name in import pivot drill formulas; do
|
||||||
|
record "$name"
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
198
src/command/cmd/cell.rs
Normal file
198
src/command/cmd/cell.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_selected_cell_produces_clear_and_dirty() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Type".to_string(), "Food".to_string()),
|
||||||
|
("Month".to_string(), "Jan".to_string()),
|
||||||
|
]);
|
||||||
|
m.model.set_cell(key, CellValue::Number(42.0));
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = ClearCellCommand {
|
||||||
|
key: ctx.cell_key().clone().unwrap(),
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yank_cell_produces_set_yanked() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Type".to_string(), "Food".to_string()),
|
||||||
|
("Month".to_string(), "Jan".to_string()),
|
||||||
|
]);
|
||||||
|
m.model.set_cell(key, CellValue::Number(99.0));
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = YankCell {
|
||||||
|
key: ctx.cell_key().clone().unwrap(),
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_with_yanked_value_produces_set_cell() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
m.model.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("Type".into(), "Food".into()),
|
||||||
|
("Month".into(), "Jan".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(42.0),
|
||||||
|
);
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let yanked = Some(CellValue::Number(99.0));
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.yanked = &yanked;
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Type".into(), "Clothing".into()),
|
||||||
|
("Month".into(), "Feb".into()),
|
||||||
|
]);
|
||||||
|
let cmd = PasteCell { key };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_without_yanked_value_produces_nothing() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("Type".into(), "Food".into()),
|
||||||
|
("Month".into(), "Jan".into()),
|
||||||
|
]);
|
||||||
|
let cmd = PasteCell { key };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transpose_produces_transpose_and_dirty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = TransposeAxes.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("TransposeAxes"),
|
||||||
|
"Expected TransposeAxes, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_produces_save_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = SaveCmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cell operations ──────────────────────────────────────────────────────────
|
||||||
|
// All cell commands take an explicit CellKey. The interactive spec fills it
|
||||||
|
// from ctx.cell_key(); the parser fills it from Cat/Item coordinate args.
|
||||||
|
|
||||||
|
/// Clear a cell.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ClearCellCommand {
|
||||||
|
pub key: crate::model::cell::CellKey,
|
||||||
|
}
|
||||||
|
impl Cmd for ClearCellCommand {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"clear-cell"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::ClearCell(self.key.clone())),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yank (copy) a cell value.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct YankCell {
|
||||||
|
pub key: crate::model::cell::CellKey,
|
||||||
|
}
|
||||||
|
impl Cmd for YankCell {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"yank"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetYanked(value)),
|
||||||
|
effect::set_status("Yanked"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paste the yanked value into a cell.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PasteCell {
|
||||||
|
pub key: crate::model::cell::CellKey,
|
||||||
|
}
|
||||||
|
impl Cmd for PasteCell {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"paste"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let Some(value) = ctx.yanked.clone() {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetCell(self.key.clone(), value)),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View commands ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TransposeAxes;
|
||||||
|
impl Cmd for TransposeAxes {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"transpose"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::TransposeAxes), effect::mark_dirty()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SaveCmd;
|
||||||
|
impl Cmd for SaveCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"save"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::Save)]
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/command/cmd/commit.rs
Normal file
455
src/command/cmd/commit.rs
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
use super::grid::AddRecordRow;
|
||||||
|
use super::navigation::{CursorState, EnterAdvance, viewport_effects};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_formula_with_categories_adds_formula() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("formula".to_string(), "Profit = Revenue - Cost".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitFormula.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AddFormula"),
|
||||||
|
"Expected AddFormula, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("FormulaPanel"),
|
||||||
|
"Expected return to FormulaPanel, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formulas always target _Measure by default, even when no regular
|
||||||
|
/// categories exist. _Measure is a virtual category that always exists.
|
||||||
|
#[test]
|
||||||
|
fn commit_formula_without_regular_categories_targets_measure() {
|
||||||
|
let m = Workbook::new("Empty");
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("formula".to_string(), "X = Y + Z".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitFormula.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AddFormula"),
|
||||||
|
"Should add formula targeting _Measure, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("_Measure"),
|
||||||
|
"target_category should be _Measure, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_category_add_with_name_produces_add_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("category".to_string(), "Region".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitCategoryAdd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AddCategory"),
|
||||||
|
"Expected AddCategory, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_category_add_with_empty_buffer_returns_to_panel() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("category".to_string(), "".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitCategoryAdd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("CategoryPanel"),
|
||||||
|
"Expected return to CategoryPanel, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_item_add_with_name_produces_add_item() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("item".to_string(), "March".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let item_add_mode = AppMode::item_add("Month".to_string());
|
||||||
|
ctx.mode = &item_add_mode;
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitItemAdd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("AddItem"), "Expected AddItem, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_item_add_outside_item_add_mode_returns_empty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = CommitItemAdd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `CommitAndAdvance` must thread its `edit_mode` through to the
|
||||||
|
/// trailing `EnterEditAtCursor` effect so the post-commit re-edit lands
|
||||||
|
/// in the mode the keymap requested. The command never reads ctx.mode.
|
||||||
|
#[test]
|
||||||
|
fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("edit".to_string(), "42".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
// ctx.mode stays Normal — the command must not look at it.
|
||||||
|
let key = ctx.cell_key().unwrap();
|
||||||
|
let cmd = CommitAndAdvance {
|
||||||
|
key,
|
||||||
|
value: "42".to_string(),
|
||||||
|
advance: super::AdvanceDir::Down,
|
||||||
|
cursor: super::CursorState::from_ctx(&ctx),
|
||||||
|
edit_mode: AppMode::records_editing(),
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("EnterEditAtCursor") && dbg.contains("RecordsEditing"),
|
||||||
|
"Expected trailing EnterEditAtCursor with RecordsEditing target, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_export_produces_export_and_normal_mode() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("export".to_string(), "/tmp/test.csv".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommitExport.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}");
|
||||||
|
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
|
||||||
|
|
||||||
|
/// Commit a cell value: for synthetic records keys, stage in drill pending edits
|
||||||
|
/// in drill mode, or apply directly in plain records mode; for real keys, write
|
||||||
|
/// to the model.
|
||||||
|
fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
|
||||||
|
if value.is_empty() {
|
||||||
|
effects.push(Box::new(effect::ClearCell(key.clone())));
|
||||||
|
} else if let Ok(n) = value.parse::<f64>() {
|
||||||
|
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
|
||||||
|
} else {
|
||||||
|
effects.push(Box::new(effect::SetCell(
|
||||||
|
key.clone(),
|
||||||
|
CellValue::Text(value.to_string()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
effects.push(effect::mark_dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a synthetic edit in drill state so it can be applied atomically on exit.
|
||||||
|
fn stage_drill_edit(record_idx: usize, col_name: String, value: &str) -> Box<dyn Effect> {
|
||||||
|
Box::new(effect::SetDrillPendingEdit {
|
||||||
|
record_idx,
|
||||||
|
col_name,
|
||||||
|
new_value: value.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a synthetic records-mode edit directly to the underlying model cell.
|
||||||
|
fn commit_plain_records_edit(
|
||||||
|
ctx: &CmdContext,
|
||||||
|
record_idx: usize,
|
||||||
|
col_name: &str,
|
||||||
|
value: &str,
|
||||||
|
effects: &mut Vec<Box<dyn Effect>>,
|
||||||
|
) {
|
||||||
|
let Some((orig_key, _)) = ctx
|
||||||
|
.layout
|
||||||
|
.records
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|records| records.get(record_idx))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if col_name == "Value" {
|
||||||
|
commit_regular_cell_value(orig_key, value, effects);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
effects.push(effect::set_status(effect::RECORD_COORDS_CANNOT_BE_EMPTY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(existing_value) = ctx.model.get_cell(orig_key).cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
effects.push(Box::new(effect::ClearCell(orig_key.clone())));
|
||||||
|
effects.push(Box::new(effect::AddItem {
|
||||||
|
category: col_name.to_string(),
|
||||||
|
item: value.to_string(),
|
||||||
|
}));
|
||||||
|
effects.push(Box::new(effect::SetCell(
|
||||||
|
orig_key.clone().with(col_name, value),
|
||||||
|
existing_value,
|
||||||
|
)));
|
||||||
|
effects.push(effect::mark_dirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_cell_value(
|
||||||
|
ctx: &CmdContext,
|
||||||
|
key: &CellKey,
|
||||||
|
value: &str,
|
||||||
|
effects: &mut Vec<Box<dyn Effect>>,
|
||||||
|
) {
|
||||||
|
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
|
||||||
|
if ctx.has_drill_state {
|
||||||
|
effects.push(stage_drill_edit(record_idx, col_name, value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commit_plain_records_edit(ctx, record_idx, &col_name, value, effects);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commit_regular_cell_value(key, value, effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direction to advance after committing a cell edit.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum AdvanceDir {
|
||||||
|
/// Move down (typewriter-style, wraps to next column at bottom).
|
||||||
|
Down,
|
||||||
|
/// Move right (clamps at rightmost column).
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the normal-mode counterpart of an editing mode. Used by
|
||||||
|
/// `CommitAndAdvance` to compute the mode to land in if the advance
|
||||||
|
/// aborts (commit + exit editing at boundary).
|
||||||
|
fn exit_mode_for(edit_mode: &AppMode) -> AppMode {
|
||||||
|
match edit_mode {
|
||||||
|
AppMode::RecordsEditing { .. } => AppMode::RecordsNormal,
|
||||||
|
_ => AppMode::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
|
||||||
|
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
|
||||||
|
///
|
||||||
|
/// `edit_mode` is the editing mode to re-enter after advancing. The keymap
|
||||||
|
/// binding supplies this — the editing-mode keymap passes `editing` and the
|
||||||
|
/// records-editing keymap passes `records-editing`. The command itself
|
||||||
|
/// never inspects `ctx.mode`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitAndAdvance {
|
||||||
|
pub key: CellKey,
|
||||||
|
pub value: String,
|
||||||
|
pub advance: AdvanceDir,
|
||||||
|
pub cursor: CursorState,
|
||||||
|
pub edit_mode: AppMode,
|
||||||
|
}
|
||||||
|
impl Cmd for CommitAndAdvance {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
match self.advance {
|
||||||
|
AdvanceDir::Down => "commit-cell-edit",
|
||||||
|
AdvanceDir::Right => "commit-and-advance-right",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
commit_cell_value(ctx, &self.key, &self.value, &mut effects);
|
||||||
|
// Pre-emptively drop to the normal counterpart of edit_mode. If the
|
||||||
|
// advance succeeds, the trailing `EnterEditAtCursor` below will lift
|
||||||
|
// us back into editing on the new cell. If the advance aborts
|
||||||
|
// (e.g. already at bottom-right on Enter), `EnterEditAtCursor` is
|
||||||
|
// skipped and we land in normal mode — which is the desired
|
||||||
|
// "Enter at bottom-right commits and exits" behavior.
|
||||||
|
effects.push(effect::change_mode(exit_mode_for(&self.edit_mode)));
|
||||||
|
match self.advance {
|
||||||
|
AdvanceDir::Down => {
|
||||||
|
let adv = EnterAdvance {
|
||||||
|
cursor: self.cursor.clone(),
|
||||||
|
};
|
||||||
|
effects.extend(adv.execute(ctx));
|
||||||
|
}
|
||||||
|
AdvanceDir::Right => {
|
||||||
|
let col_max = self.cursor.col_count.saturating_sub(1);
|
||||||
|
let row_max = self.cursor.row_count.saturating_sub(1);
|
||||||
|
let at_bottom_right = self.cursor.row >= row_max && self.cursor.col >= col_max;
|
||||||
|
|
||||||
|
if at_bottom_right && ctx.is_records_mode() {
|
||||||
|
let add = AddRecordRow;
|
||||||
|
effects.extend(add.execute(ctx));
|
||||||
|
effects.extend(viewport_effects(
|
||||||
|
self.cursor.row + 1,
|
||||||
|
0,
|
||||||
|
self.cursor.row_offset,
|
||||||
|
self.cursor.col_offset,
|
||||||
|
self.cursor.visible_rows,
|
||||||
|
self.cursor.visible_cols,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let nc = (self.cursor.col + 1).min(col_max);
|
||||||
|
effects.extend(viewport_effects(
|
||||||
|
self.cursor.row,
|
||||||
|
nc,
|
||||||
|
self.cursor.row_offset,
|
||||||
|
self.cursor.col_offset,
|
||||||
|
self.cursor.visible_rows,
|
||||||
|
self.cursor.visible_cols,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects.push(Box::new(effect::EnterEditAtCursor {
|
||||||
|
target_mode: self.edit_mode.clone(),
|
||||||
|
}));
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit a formula from the formula edit buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitFormula;
|
||||||
|
impl Cmd for CommitFormula {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"commit-formula"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let buf = ctx.buffers.get("formula").cloned().unwrap_or_default();
|
||||||
|
// Default formula target to _Measure (the virtual measure category).
|
||||||
|
// _Measure dynamically includes all formula targets.
|
||||||
|
vec![
|
||||||
|
Box::new(effect::AddFormula {
|
||||||
|
raw: buf,
|
||||||
|
target_category: "_Measure".to_string(),
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status("Formula added"),
|
||||||
|
effect::change_mode(AppMode::FormulaPanel),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helper: read a buffer, trim it, and if non-empty, produce add + dirty
|
||||||
|
/// + status effects. If empty, return to CategoryPanel.
|
||||||
|
///
|
||||||
|
/// Buffer clearing is handled by the keymap (Enter → [commit, clear-buffer]).
|
||||||
|
fn commit_add_from_buffer(
|
||||||
|
ctx: &CmdContext,
|
||||||
|
buffer_name: &str,
|
||||||
|
add_effect: impl FnOnce(&str) -> Option<Box<dyn Effect>>,
|
||||||
|
status_msg: impl FnOnce(&str) -> String,
|
||||||
|
) -> Vec<Box<dyn Effect>> {
|
||||||
|
let buf = ctx.buffers.get(buffer_name).cloned().unwrap_or_default();
|
||||||
|
let trimmed = buf.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return vec![effect::change_mode(AppMode::CategoryPanel)];
|
||||||
|
}
|
||||||
|
let Some(add) = add_effect(&trimmed) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
add,
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(status_msg(&trimmed)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit adding a category, staying in CategoryAdd mode for the next entry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitCategoryAdd;
|
||||||
|
impl Cmd for CommitCategoryAdd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"commit-category-add"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
commit_add_from_buffer(
|
||||||
|
ctx,
|
||||||
|
"category",
|
||||||
|
|name| Some(Box::new(effect::AddCategory(name.to_string()))),
|
||||||
|
|name| format!("Added category \"{name}\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit adding an item, staying in ItemAdd mode for the next entry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitItemAdd;
|
||||||
|
impl Cmd for CommitItemAdd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"commit-item-add"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let category = if let AppMode::ItemAdd { category, .. } = ctx.mode {
|
||||||
|
category.clone()
|
||||||
|
} else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
commit_add_from_buffer(
|
||||||
|
ctx,
|
||||||
|
"item",
|
||||||
|
|name| {
|
||||||
|
Some(Box::new(effect::AddItem {
|
||||||
|
category: category.clone(),
|
||||||
|
item: name.to_string(),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|name| format!("Added \"{name}\""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit an export from the export buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommitExport;
|
||||||
|
impl Cmd for CommitExport {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"commit-export"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let buf = ctx.buffers.get("export").cloned().unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
Box::new(effect::ExportCsv(std::path::PathBuf::from(buf))),
|
||||||
|
effect::change_mode(AppMode::Normal),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/command/cmd/core.rs
Normal file
319
src/command/cmd/core.rs
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{Effect, Panel};
|
||||||
|
use crate::view::{Axis, GridLayout, View};
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
|
/// Read-only context available to commands for decision-making.
|
||||||
|
///
|
||||||
|
/// Commands receive a `&Model` (pure data) and a `&View` (the active view).
|
||||||
|
/// The full `&Workbook` is also available for the rare command that needs
|
||||||
|
/// to enumerate all views (e.g. the view panel).
|
||||||
|
pub struct CmdContext<'a> {
|
||||||
|
pub model: &'a Model,
|
||||||
|
pub workbook: &'a Workbook,
|
||||||
|
pub view: &'a View,
|
||||||
|
pub layout: &'a GridLayout,
|
||||||
|
pub registry: &'a CmdRegistry,
|
||||||
|
pub mode: &'a AppMode,
|
||||||
|
pub selected: (usize, usize),
|
||||||
|
pub row_offset: usize,
|
||||||
|
pub col_offset: usize,
|
||||||
|
pub search_query: &'a str,
|
||||||
|
pub yanked: &'a Option<CellValue>,
|
||||||
|
pub dirty: bool,
|
||||||
|
pub search_mode: bool,
|
||||||
|
pub formula_panel_open: bool,
|
||||||
|
pub category_panel_open: bool,
|
||||||
|
pub view_panel_open: bool,
|
||||||
|
/// Panel cursors
|
||||||
|
pub formula_cursor: usize,
|
||||||
|
pub cat_panel_cursor: usize,
|
||||||
|
pub view_panel_cursor: usize,
|
||||||
|
/// Tile select cursor (which category is selected)
|
||||||
|
pub tile_cat_idx: usize,
|
||||||
|
/// Named text buffers
|
||||||
|
pub buffers: &'a HashMap<String, String>,
|
||||||
|
/// View navigation stacks (for drill back/forward)
|
||||||
|
pub view_back_stack: &'a [crate::ui::app::ViewFrame],
|
||||||
|
pub view_forward_stack: &'a [crate::ui::app::ViewFrame],
|
||||||
|
/// Whether the app currently has an active drill snapshot.
|
||||||
|
pub has_drill_state: bool,
|
||||||
|
/// Display value at the cursor — works uniformly for pivot and records mode.
|
||||||
|
pub display_value: String,
|
||||||
|
/// How many data rows/cols fit on screen (for viewport scrolling).
|
||||||
|
pub visible_rows: usize,
|
||||||
|
pub visible_cols: usize,
|
||||||
|
/// Expanded categories in the tree panel
|
||||||
|
pub expanded_cats: &'a std::collections::HashSet<String>,
|
||||||
|
/// The key that triggered this command
|
||||||
|
pub key_code: KeyCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CmdContext<'a> {
|
||||||
|
/// Return true when the current layout is a records-mode layout.
|
||||||
|
pub fn is_records_mode(&self) -> bool {
|
||||||
|
self.layout.is_records_mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cell_key(&self) -> Option<CellKey> {
|
||||||
|
self.layout.cell_key(self.selected.0, self.selected.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return synthetic record coordinates for the current cursor, if any.
|
||||||
|
pub fn synthetic_record_at_cursor(&self) -> Option<(usize, String)> {
|
||||||
|
self.cell_key()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(crate::view::synthetic_record_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row_count(&self) -> usize {
|
||||||
|
self.layout.row_count()
|
||||||
|
}
|
||||||
|
pub fn col_count(&self) -> usize {
|
||||||
|
self.layout.col_count()
|
||||||
|
}
|
||||||
|
pub fn none_cats(&self) -> &[String] {
|
||||||
|
&self.layout.none_cats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CmdContext<'a> {
|
||||||
|
/// Resolve the category panel tree entry at the current cursor.
|
||||||
|
pub fn cat_tree_entry(&self) -> Option<crate::ui::cat_tree::CatTreeEntry> {
|
||||||
|
let tree = crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats);
|
||||||
|
tree.into_iter().nth(self.cat_panel_cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The category name at the current tree cursor (whether on a
|
||||||
|
/// category header or an item).
|
||||||
|
pub fn cat_at_cursor(&self) -> Option<String> {
|
||||||
|
self.cat_tree_entry().map(|e| e.cat_name().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of entries in the category tree.
|
||||||
|
pub fn cat_tree_len(&self) -> usize {
|
||||||
|
crate::ui::cat_tree::build_cat_tree(self.model, self.expanded_cats).len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command that reads state and produces effects.
|
||||||
|
pub trait Cmd: Debug + Send + Sync {
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
|
||||||
|
/// The canonical name of this command (matches its registry key).
|
||||||
|
/// Used by the parser tests and for introspection.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory that constructs a Cmd from text arguments (headless/script).
|
||||||
|
pub type ParseFn = fn(&[String]) -> Result<Box<dyn Cmd>, String>;
|
||||||
|
|
||||||
|
/// Factory that constructs a Cmd from the interactive context (keymap dispatch).
|
||||||
|
/// Receives both the keymap args and the interactive context so commands can
|
||||||
|
/// combine text arguments (e.g. panel name) with runtime state (e.g. whether
|
||||||
|
/// the panel is currently open).
|
||||||
|
pub type InteractiveFn = fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>;
|
||||||
|
|
||||||
|
type BoxParseFn = Box<dyn Fn(&[String]) -> Result<Box<dyn Cmd>, String>>;
|
||||||
|
type BoxInteractiveFn = Box<dyn Fn(&[String], &CmdContext) -> Result<Box<dyn Cmd>, String>>;
|
||||||
|
|
||||||
|
/// A registered command entry with both text and interactive constructors.
|
||||||
|
struct CmdEntry {
|
||||||
|
name: &'static str,
|
||||||
|
parse: BoxParseFn,
|
||||||
|
interactive: BoxInteractiveFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of commands constructible from text or from interactive context.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct CmdRegistry {
|
||||||
|
entries: Vec<CmdEntry>,
|
||||||
|
aliases: Vec<(&'static str, &'static str)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CmdRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Vec::new(),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a short name that resolves to a canonical command name.
|
||||||
|
pub fn alias(&mut self, short: &'static str, canonical: &'static str) {
|
||||||
|
self.aliases.push((short, canonical));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a command name through the alias table.
|
||||||
|
fn resolve<'a>(&'a self, name: &'a str) -> &'a str {
|
||||||
|
for (alias, canonical) in &self.aliases {
|
||||||
|
if *alias == name {
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a command with both a text parser and an interactive constructor.
|
||||||
|
/// The name is derived from a prototype command instance.
|
||||||
|
pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) {
|
||||||
|
self.entries.push(CmdEntry {
|
||||||
|
name: prototype.name(),
|
||||||
|
parse: Box::new(parse),
|
||||||
|
interactive: Box::new(interactive),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a command that doesn't need interactive context.
|
||||||
|
/// When called interactively with args, delegates to parse.
|
||||||
|
/// When called interactively without args, returns an error.
|
||||||
|
pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) {
|
||||||
|
self.entries.push(CmdEntry {
|
||||||
|
name: prototype.name(),
|
||||||
|
parse: Box::new(parse),
|
||||||
|
interactive: Box::new(move |args, _ctx| {
|
||||||
|
if args.is_empty() {
|
||||||
|
Err("this command requires arguments".into())
|
||||||
|
} else {
|
||||||
|
parse(args)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a zero-arg command (same instance for parse and interactive).
|
||||||
|
/// The name is derived by calling `f()` once.
|
||||||
|
pub fn register_nullary(&mut self, f: fn() -> Box<dyn Cmd>) {
|
||||||
|
let name = f().name();
|
||||||
|
self.entries.push(CmdEntry {
|
||||||
|
name,
|
||||||
|
parse: Box::new(move |_| Ok(f())),
|
||||||
|
interactive: Box::new(move |_, _| Ok(f())),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a command from text arguments (script/headless).
|
||||||
|
pub fn parse(&self, name: &str, args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
||||||
|
let name = self.resolve(name);
|
||||||
|
for e in &self.entries {
|
||||||
|
if e.name == name {
|
||||||
|
return (e.parse)(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("Unknown command: {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a command from interactive context (keymap dispatch).
|
||||||
|
/// Always calls the interactive constructor with both args and ctx,
|
||||||
|
/// so commands can combine text arguments with runtime state.
|
||||||
|
pub fn interactive(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
args: &[String],
|
||||||
|
ctx: &CmdContext,
|
||||||
|
) -> Result<Box<dyn Cmd>, String> {
|
||||||
|
let name = self.resolve(name);
|
||||||
|
for e in &self.entries {
|
||||||
|
if e.name == name {
|
||||||
|
return (e.interactive)(args, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("Unknown command: {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||||
|
self.entries.iter().map(|e| e.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dummy prototype used only for name extraction in registry calls
|
||||||
|
/// where the real command struct is built by a closure.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct NamedCmd(pub(super) &'static str);
|
||||||
|
impl Cmd for NamedCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn execute(&self, _: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn require_args(word: &str, args: &[String], n: usize) -> Result<(), String> {
|
||||||
|
if args.len() < n {
|
||||||
|
Err(format!(
|
||||||
|
"{word} requires {n} argument(s), got {}",
|
||||||
|
args.len()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Cat/Item coordinate args into a CellKey.
|
||||||
|
pub(super) fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey {
|
||||||
|
let coords: Vec<(String, String)> = args
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
let (cat, item) = s.split_once('/')?;
|
||||||
|
Some((cat.to_string(), item.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
crate::model::cell::CellKey::new(coords)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current value of a named buffer from context.
|
||||||
|
pub(super) fn read_buffer(ctx: &CmdContext, name: &str) -> String {
|
||||||
|
if name == "search" {
|
||||||
|
ctx.search_query.to_string()
|
||||||
|
} else {
|
||||||
|
ctx.buffers.get(name).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_panel(s: &str) -> Result<Panel, String> {
|
||||||
|
match s {
|
||||||
|
"formula" => Ok(Panel::Formula),
|
||||||
|
"category" => Ok(Panel::Category),
|
||||||
|
"view" => Ok(Panel::View),
|
||||||
|
other => Err(format!("Unknown panel: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_axis(s: &str) -> Result<Axis, String> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"row" => Ok(Axis::Row),
|
||||||
|
"column" | "col" => Ok(Axis::Column),
|
||||||
|
"page" => Ok(Axis::Page),
|
||||||
|
"none" => Ok(Axis::None),
|
||||||
|
other => Err(format!("Unknown axis: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_axis_recognizes_all_variants() {
|
||||||
|
assert!(parse_axis("row").is_ok());
|
||||||
|
assert!(parse_axis("column").is_ok());
|
||||||
|
assert!(parse_axis("col").is_ok());
|
||||||
|
assert!(parse_axis("page").is_ok());
|
||||||
|
assert!(parse_axis("none").is_ok());
|
||||||
|
assert!(parse_axis("ROW").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_axis_rejects_unknown() {
|
||||||
|
assert!(parse_axis("diagonal").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/command/cmd/effect_cmds.rs
Normal file
466
src/command/cmd/effect_cmds.rs
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext, require_args};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_category_cmd_produces_add_category_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = AddCategoryCmd(vec!["Region".to_string()]);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AddCategory"),
|
||||||
|
"Expected AddCategory, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_cell_cmd_parses_coords_correctly() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = SetCellCmd(vec![
|
||||||
|
"42".to_string(),
|
||||||
|
"Type/Food".to_string(),
|
||||||
|
"Month/Jan".to_string(),
|
||||||
|
]);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SetCell"), "Expected SetCell, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_axis_cmd_recognizes_column_alias() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_cmd_without_args_saves() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = WriteCmd(vec![]);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_cmd_with_path_saves_as() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = WriteCmd(vec!["/tmp/out.improv".to_string()]);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SaveAs"), "Expected SaveAs, got: {dbg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parseable model-mutation commands ────────────────────────────────────────
|
||||||
|
// These are thin Cmd wrappers around effects, constructible from string args.
|
||||||
|
// They share the same execution path as keymap-dispatched commands.
|
||||||
|
|
||||||
|
macro_rules! effect_cmd {
|
||||||
|
($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => {
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct $name(pub Vec<String>);
|
||||||
|
impl Cmd for $name {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
$cmd_name
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let args = &self.0;
|
||||||
|
#[allow(clippy::redundant_closure_call)]
|
||||||
|
($exec)(args, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl $name {
|
||||||
|
pub fn parse(args: &[String]) -> Result<Box<dyn Cmd>, String> {
|
||||||
|
#[allow(clippy::redundant_closure_call)]
|
||||||
|
($parse)(args)?;
|
||||||
|
Ok(Box::new($name(args.to_vec())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
AddCategoryCmd,
|
||||||
|
"add-category",
|
||||||
|
|args: &[String]| require_args("add-category", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::AddCategory(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
AddItemCmd,
|
||||||
|
"add-item",
|
||||||
|
|args: &[String]| require_args("add-item", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::AddItem {
|
||||||
|
category: args[0].clone(),
|
||||||
|
item: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
AddItemsCmd,
|
||||||
|
"add-items",
|
||||||
|
|args: &[String]| {
|
||||||
|
if args.len() < 2 {
|
||||||
|
Err("add-items requires a category and at least one item".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let category = &args[0];
|
||||||
|
args[1..]
|
||||||
|
.iter()
|
||||||
|
.map(|item| -> Box<dyn Effect> {
|
||||||
|
Box::new(effect::AddItem {
|
||||||
|
category: category.clone(),
|
||||||
|
item: item.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
AddItemInGroupCmd,
|
||||||
|
"add-item-in-group",
|
||||||
|
|args: &[String]| require_args("add-item-in-group", args, 3),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::AddItemInGroup {
|
||||||
|
category: args[0].clone(),
|
||||||
|
item: args[1].clone(),
|
||||||
|
group: args[2].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SetCellCmd,
|
||||||
|
"set-cell",
|
||||||
|
|args: &[String]| {
|
||||||
|
if args.len() < 2 {
|
||||||
|
Err("set-cell requires a value and at least one Cat/Item coordinate".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let value = if let Ok(n) = args[0].parse::<f64>() {
|
||||||
|
CellValue::Number(n)
|
||||||
|
} else {
|
||||||
|
CellValue::Text(args[0].clone())
|
||||||
|
};
|
||||||
|
let coords: Vec<(String, String)> = args[1..]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
let (cat, item) = s.split_once('/')?;
|
||||||
|
Some((cat.to_string(), item.to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let key = crate::model::cell::CellKey::new(coords);
|
||||||
|
vec![Box::new(effect::SetCell(key, value))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
AddFormulaCmd,
|
||||||
|
"add-formula",
|
||||||
|
|args: &[String]| {
|
||||||
|
if args.is_empty() || args.len() > 2 {
|
||||||
|
return Err(format!(
|
||||||
|
"add-formula requires 1-2 argument(s), got {}",
|
||||||
|
args.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
// 1 arg: formula text (target_category defaults to _Measure)
|
||||||
|
// 2 args: target_category, formula text
|
||||||
|
let (cat, raw) = if args.len() == 2 {
|
||||||
|
(args[0].clone(), args[1].clone())
|
||||||
|
} else {
|
||||||
|
("_Measure".to_string(), args[0].clone())
|
||||||
|
};
|
||||||
|
vec![Box::new(effect::AddFormula {
|
||||||
|
target_category: cat,
|
||||||
|
raw,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ClearBufferCmd,
|
||||||
|
"clear-buffer",
|
||||||
|
|args: &[String]| require_args("clear-buffer", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SetBuffer {
|
||||||
|
name: args[0].clone(),
|
||||||
|
value: String::new(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
RemoveFormulaCmd,
|
||||||
|
"remove-formula",
|
||||||
|
|args: &[String]| require_args("remove-formula", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::RemoveFormula {
|
||||||
|
target_category: args[0].clone(),
|
||||||
|
target: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
CreateViewCmd,
|
||||||
|
"create-view",
|
||||||
|
|args: &[String]| require_args("create-view", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::CreateView(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
DeleteViewCmd,
|
||||||
|
"delete-view",
|
||||||
|
|args: &[String]| require_args("delete-view", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::DeleteView(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SwitchViewCmd,
|
||||||
|
"switch-view",
|
||||||
|
|args: &[String]| require_args("switch-view", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SwitchView(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SetAxisCmd,
|
||||||
|
"set-axis",
|
||||||
|
|args: &[String]| require_args("set-axis", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let axis = match args[1].to_lowercase().as_str() {
|
||||||
|
"row" => Axis::Row,
|
||||||
|
"column" | "col" => Axis::Column,
|
||||||
|
"page" => Axis::Page,
|
||||||
|
"none" => Axis::None,
|
||||||
|
_ => return vec![], // parse step already validated
|
||||||
|
};
|
||||||
|
vec![Box::new(effect::SetAxis {
|
||||||
|
category: args[0].clone(),
|
||||||
|
axis,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SetPageCmd,
|
||||||
|
"set-page",
|
||||||
|
|args: &[String]| require_args("set-page", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SetPageSelection {
|
||||||
|
category: args[0].clone(),
|
||||||
|
item: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ToggleGroupCmd,
|
||||||
|
"toggle-group",
|
||||||
|
|args: &[String]| require_args("toggle-group", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::ToggleGroup {
|
||||||
|
category: args[0].clone(),
|
||||||
|
group: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HideItemCmd,
|
||||||
|
"hide-item",
|
||||||
|
|args: &[String]| require_args("hide-item", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::HideItem {
|
||||||
|
category: args[0].clone(),
|
||||||
|
item: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ShowItemCmd,
|
||||||
|
"show-item",
|
||||||
|
|args: &[String]| require_args("show-item", args, 2),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::ShowItem {
|
||||||
|
category: args[0].clone(),
|
||||||
|
item: args[1].clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SaveAsCmd,
|
||||||
|
"save-as",
|
||||||
|
|args: &[String]| require_args("save-as", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
SetFormatCmd,
|
||||||
|
"set-format",
|
||||||
|
|args: &[String]| require_args("set-format", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetNumberFormat(args.join(" "))),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ImportCmd,
|
||||||
|
"import",
|
||||||
|
|args: &[String]| require_args("import", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::StartImportWizard(args[0].clone()))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ExportCmd,
|
||||||
|
"export",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv");
|
||||||
|
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(path)))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
WriteCmd,
|
||||||
|
"w",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
if args.is_empty() {
|
||||||
|
vec![Box::new(effect::Save)]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpCmd,
|
||||||
|
"help",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![
|
||||||
|
effect::help_page_set(0),
|
||||||
|
effect::change_mode(crate::ui::app::AppMode::Help),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpPageNextCmd,
|
||||||
|
"help-page-next",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::help_page_next()]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
HelpPagePrevCmd,
|
||||||
|
"help-page-prev",
|
||||||
|
|_args: &[String]| -> Result<(), String> { Ok(()) },
|
||||||
|
|_args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::help_page_prev()]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
LoadModelCmd,
|
||||||
|
"load",
|
||||||
|
|args: &[String]| require_args("load", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::LoadModel(std::path::PathBuf::from(
|
||||||
|
&args[0],
|
||||||
|
)))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ExportCsvCmd,
|
||||||
|
"export-csv",
|
||||||
|
|args: &[String]| require_args("export-csv", args, 1),
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::ExportCsv(std::path::PathBuf::from(
|
||||||
|
&args[0],
|
||||||
|
)))]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
effect_cmd!(
|
||||||
|
ImportJsonCmd,
|
||||||
|
"import-json",
|
||||||
|
|args: &[String]| {
|
||||||
|
if args.is_empty() {
|
||||||
|
Err("import-json requires a path".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|args: &Vec<String>, _ctx: &CmdContext| -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::ImportJsonHeadless {
|
||||||
|
path: std::path::PathBuf::from(&args[0]),
|
||||||
|
model_name: args.get(1).cloned(),
|
||||||
|
array_path: args.get(2).cloned(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
);
|
||||||
494
src/command/cmd/grid.rs
Normal file
494
src/command/cmd/grid.rs
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::AxisEntry;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_group_under_cursor_returns_empty_without_groups() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = ToggleGroupAtCursor { is_row: true };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn law_toggle_group_involution() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = ToggleGroupAtCursor { is_row: true };
|
||||||
|
let first = effects_debug(&cmd.execute(&ctx));
|
||||||
|
let second = effects_debug(&cmd.execute(&ctx));
|
||||||
|
assert_eq!(first, second, "Toggle should be structurally consistent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_forward_with_empty_stack_shows_status() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = ViewNavigate { forward: true };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("No forward view"),
|
||||||
|
"Expected status message, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_back_with_empty_stack_shows_status() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = ViewNavigate { forward: false };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("No previous view"),
|
||||||
|
"Expected status message, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_forward_with_stack_produces_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let fwd_stack = vec![crate::ui::app::ViewFrame {
|
||||||
|
view_name: "View 2".to_string(),
|
||||||
|
mode: crate::ui::app::AppMode::Normal,
|
||||||
|
}];
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.view_forward_stack = &fwd_stack;
|
||||||
|
let cmd = ViewNavigate { forward: true };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("ViewForward"),
|
||||||
|
"Expected ViewForward, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_back_with_stack_produces_apply_and_back() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let back_stack = vec![crate::ui::app::ViewFrame {
|
||||||
|
view_name: "Default".to_string(),
|
||||||
|
mode: crate::ui::app::AppMode::Normal,
|
||||||
|
}];
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.view_back_stack = &back_stack;
|
||||||
|
let cmd = ViewNavigate { forward: false };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("ApplyAndClearDrill"),
|
||||||
|
"Expected ApplyAndClearDrill, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(dbg.contains("ViewBack"), "Expected ViewBack, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_prune_empty_produces_toggle_and_dirty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = TogglePruneEmpty.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("TogglePruneEmpty"),
|
||||||
|
"Expected TogglePruneEmpty, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drilling into a formula cell (e.g. Profit = Revenue - Cost) should
|
||||||
|
/// return the underlying data records, not an empty result set. The
|
||||||
|
/// formula target coordinate is stripped from the drill key so that
|
||||||
|
/// matching_cells finds the raw data backing the formula.
|
||||||
|
#[test]
|
||||||
|
fn drill_into_formula_cell_returns_data_records() {
|
||||||
|
use crate::formula::parse_formula;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
|
let mut m = Workbook::new("Test");
|
||||||
|
m.add_category("Region").unwrap();
|
||||||
|
m.model.category_mut("Region").unwrap().add_item("East");
|
||||||
|
m.model
|
||||||
|
.category_mut("_Measure")
|
||||||
|
.unwrap()
|
||||||
|
.add_item("Revenue");
|
||||||
|
m.model.category_mut("_Measure").unwrap().add_item("Cost");
|
||||||
|
m.model.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("_Measure".into(), "Revenue".into()),
|
||||||
|
("Region".into(), "East".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(1000.0),
|
||||||
|
);
|
||||||
|
m.model.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("_Measure".into(), "Cost".into()),
|
||||||
|
("Region".into(), "East".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(600.0),
|
||||||
|
);
|
||||||
|
m.model
|
||||||
|
.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
|
||||||
|
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
|
||||||
|
// Drill into the Profit/East cell — a formula-derived cell
|
||||||
|
let key = CellKey::new(vec![
|
||||||
|
("_Measure".into(), "Profit".into()),
|
||||||
|
("Region".into(), "East".into()),
|
||||||
|
]);
|
||||||
|
let cmd = DrillIntoCell { key };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
|
||||||
|
// Should find underlying data records, not "0 rows"
|
||||||
|
assert!(
|
||||||
|
!dbg.contains("0 rows"),
|
||||||
|
"Drill into formula cell should find data records, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid operations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Toggle the row or column group collapse under the cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleGroupAtCursor {
|
||||||
|
pub is_row: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for ToggleGroupAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.is_row {
|
||||||
|
"toggle-group-under-cursor"
|
||||||
|
} else {
|
||||||
|
"toggle-col-group-under-cursor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let lookup = if self.is_row {
|
||||||
|
ctx.layout.row_group_for(ctx.selected.0)
|
||||||
|
} else {
|
||||||
|
ctx.layout.col_group_for(ctx.selected.1)
|
||||||
|
};
|
||||||
|
let Some((cat, group)) = lookup else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Box::new(effect::ToggleGroup {
|
||||||
|
category: cat,
|
||||||
|
group,
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the row item at the cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HideSelectedRowItem;
|
||||||
|
impl Cmd for HideSelectedRowItem {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"hide-selected-row-item"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let Some(cat_name) = ctx.layout.row_cats.first().cloned() else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let sel_row = ctx.selected.0;
|
||||||
|
let Some(items) = ctx
|
||||||
|
.layout
|
||||||
|
.row_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
if let AxisEntry::DataItem(v) = e {
|
||||||
|
Some(v)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.nth(sel_row)
|
||||||
|
else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let item_name = items[0].clone();
|
||||||
|
vec![
|
||||||
|
Box::new(effect::HideItem {
|
||||||
|
category: cat_name,
|
||||||
|
item: item_name,
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate back or forward in view history.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ViewNavigate {
|
||||||
|
pub forward: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for ViewNavigate {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.forward {
|
||||||
|
"view-forward"
|
||||||
|
} else {
|
||||||
|
"view-back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if self.forward {
|
||||||
|
if ctx.view_forward_stack.is_empty() {
|
||||||
|
vec![effect::set_status("No forward view")]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::ViewForward)]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ctx.view_back_stack.is_empty() {
|
||||||
|
vec![effect::set_status("No previous view")]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::ApplyAndClearDrill),
|
||||||
|
Box::new(effect::ViewBack),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drill down into an aggregated cell: create a _Drill view with _Index on
|
||||||
|
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
|
||||||
|
/// from the drilled cell become page filters.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DrillIntoCell {
|
||||||
|
pub key: crate::model::cell::CellKey,
|
||||||
|
}
|
||||||
|
impl Cmd for DrillIntoCell {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"drill-into-cell"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let drill_name = "_Drill".to_string();
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
|
||||||
|
// If drilling into a formula cell, strip the formula target from the
|
||||||
|
// key so matching_cells finds the underlying raw data records instead
|
||||||
|
// of returning nothing.
|
||||||
|
let drill_key = if let Some(measure_val) = self.key.get("_Measure") {
|
||||||
|
let is_formula_target = ctx
|
||||||
|
.model
|
||||||
|
.formulas()
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.target_category == "_Measure" && f.target == measure_val);
|
||||||
|
if is_formula_target {
|
||||||
|
self.key.without("_Measure")
|
||||||
|
} else {
|
||||||
|
self.key.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.key.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture the records snapshot NOW (before we switch views).
|
||||||
|
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
|
||||||
|
if drill_key.0.is_empty() {
|
||||||
|
ctx.model
|
||||||
|
.data
|
||||||
|
.iter_cells()
|
||||||
|
.map(|(k, v)| (k, v.clone()))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
ctx.model
|
||||||
|
.data
|
||||||
|
.matching_cells(&drill_key.0)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, v.clone()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let n = records.len();
|
||||||
|
|
||||||
|
// Freeze the snapshot in the drill state
|
||||||
|
effects.push(Box::new(effect::StartDrill(records)));
|
||||||
|
|
||||||
|
// Create (or replace) the drill view
|
||||||
|
effects.push(Box::new(effect::CreateView(drill_name.clone())));
|
||||||
|
effects.push(Box::new(effect::SwitchView(drill_name)));
|
||||||
|
|
||||||
|
// Records mode: _Index on Row, _Dim on Column
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Index".to_string(),
|
||||||
|
axis: crate::view::Axis::Row,
|
||||||
|
}));
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Dim".to_string(),
|
||||||
|
axis: crate::view::Axis::Column,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fixed coords (from drilled cell) -> Page with that value as filter
|
||||||
|
let fixed_cats: std::collections::HashSet<String> =
|
||||||
|
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
||||||
|
for (cat, item) in &self.key.0 {
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: cat.clone(),
|
||||||
|
axis: crate::view::Axis::Page,
|
||||||
|
}));
|
||||||
|
effects.push(Box::new(effect::SetPageSelection {
|
||||||
|
category: cat.clone(),
|
||||||
|
item: item.clone(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Previously-aggregated categories (none_cats) stay on Axis::None so
|
||||||
|
// they don't filter records; they'll appear as columns in records mode.
|
||||||
|
// Skip virtual categories — we already set _Index/_Dim above.
|
||||||
|
for cat in ctx.none_cats() {
|
||||||
|
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: cat.clone(),
|
||||||
|
axis: crate::view::Axis::None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
effects.push(effect::set_status(format!("Drilled into cell: {n} rows")));
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle pruning of empty rows/columns in the current view.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TogglePruneEmpty;
|
||||||
|
impl Cmd for TogglePruneEmpty {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-prune-empty"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let currently_on = ctx.view.prune_empty;
|
||||||
|
vec![
|
||||||
|
Box::new(effect::TogglePruneEmpty),
|
||||||
|
effect::set_status(if currently_on {
|
||||||
|
"Showing all rows/columns"
|
||||||
|
} else {
|
||||||
|
"Hiding empty rows/columns"
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle between records mode and pivot mode using the view stack.
|
||||||
|
/// Entering records mode creates a `_Records` view and switches to it.
|
||||||
|
/// Leaving records mode navigates back to the previous view.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleRecordsMode;
|
||||||
|
impl Cmd for ToggleRecordsMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-records-mode"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let is_records = ctx.layout.is_records_mode();
|
||||||
|
|
||||||
|
if is_records {
|
||||||
|
// Leaving records mode: clean up any records with empty CellKeys
|
||||||
|
// (produced by AddRecordRow when no page filters are set) before
|
||||||
|
// restoring the previous view. This is the inverse of `SortData`
|
||||||
|
// that runs on entry.
|
||||||
|
return vec![
|
||||||
|
Box::new(effect::CleanEmptyRecords),
|
||||||
|
Box::new(effect::ViewBack),
|
||||||
|
effect::set_status("Pivot mode"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
let records_name = "_Records".to_string();
|
||||||
|
|
||||||
|
effects.push(Box::new(effect::SortData));
|
||||||
|
|
||||||
|
// Create (or replace) a _Records view and switch to it
|
||||||
|
effects.push(Box::new(effect::CreateView(records_name.clone())));
|
||||||
|
effects.push(Box::new(effect::SwitchView(records_name)));
|
||||||
|
|
||||||
|
// _Index on Row, _Dim on Column, everything else -> None
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Index".to_string(),
|
||||||
|
axis: crate::view::Axis::Row,
|
||||||
|
}));
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Dim".to_string(),
|
||||||
|
axis: crate::view::Axis::Column,
|
||||||
|
}));
|
||||||
|
for name in ctx.model.categories.keys() {
|
||||||
|
if name != "_Index" && name != "_Dim" {
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: name.clone(),
|
||||||
|
axis: crate::view::Axis::None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects.push(effect::change_mode(AppMode::RecordsNormal));
|
||||||
|
effects.push(effect::set_status("Records mode"));
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In records mode, add a new row with an empty value. The new cell gets
|
||||||
|
/// coords from the current page filters. In pivot mode, this is a no-op.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AddRecordRow;
|
||||||
|
impl Cmd for AddRecordRow {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"add-record-row"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if !ctx.is_records_mode() {
|
||||||
|
return vec![effect::set_status(
|
||||||
|
"add-record-row only works in records mode",
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
// Build a CellKey from the current page filters
|
||||||
|
let view = ctx.view;
|
||||||
|
let page_cats: Vec<String> = view
|
||||||
|
.categories_on(crate::view::Axis::Page)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
let coords: Vec<(String, String)> = page_cats
|
||||||
|
.iter()
|
||||||
|
.map(|cat| {
|
||||||
|
let sel = view.page_selection(cat).unwrap_or("").to_string();
|
||||||
|
(cat.clone(), sel)
|
||||||
|
})
|
||||||
|
.filter(|(_, v)| !v.is_empty())
|
||||||
|
.collect();
|
||||||
|
let key = crate::model::cell::CellKey::new(coords);
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status("Added new record row"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/command/cmd/mod.rs
Normal file
124
src/command/cmd/mod.rs
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
pub mod cell;
|
||||||
|
pub mod commit;
|
||||||
|
pub mod core;
|
||||||
|
pub mod effect_cmds;
|
||||||
|
pub mod grid;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod panel;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod search;
|
||||||
|
pub mod text_buffer;
|
||||||
|
pub mod tile;
|
||||||
|
|
||||||
|
// Re-export items used by external code
|
||||||
|
pub use self::core::{Cmd, CmdContext, CmdRegistry};
|
||||||
|
pub use registry::default_registry;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) mod test_helpers {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::Effect;
|
||||||
|
use crate::view::GridLayout;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
|
use super::core::CmdContext;
|
||||||
|
use super::registry::default_registry;
|
||||||
|
|
||||||
|
pub type CmdRegistry = super::core::CmdRegistry;
|
||||||
|
|
||||||
|
pub static EMPTY_BUFFERS: std::sync::LazyLock<HashMap<String, String>> =
|
||||||
|
std::sync::LazyLock::new(HashMap::new);
|
||||||
|
pub static EMPTY_EXPANDED: std::sync::LazyLock<std::collections::HashSet<String>> =
|
||||||
|
std::sync::LazyLock::new(std::collections::HashSet::new);
|
||||||
|
|
||||||
|
pub fn make_layout(workbook: &Workbook) -> GridLayout {
|
||||||
|
GridLayout::new(&workbook.model, workbook.active_view())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_ctx<'a>(
|
||||||
|
workbook: &'a Workbook,
|
||||||
|
layout: &'a GridLayout,
|
||||||
|
registry: &'a CmdRegistry,
|
||||||
|
) -> CmdContext<'a> {
|
||||||
|
let view = workbook.active_view();
|
||||||
|
let (sr, sc) = view.selected;
|
||||||
|
CmdContext {
|
||||||
|
model: &workbook.model,
|
||||||
|
workbook,
|
||||||
|
view,
|
||||||
|
layout,
|
||||||
|
registry,
|
||||||
|
mode: &AppMode::Normal,
|
||||||
|
selected: view.selected,
|
||||||
|
row_offset: view.row_offset,
|
||||||
|
col_offset: view.col_offset,
|
||||||
|
search_query: "",
|
||||||
|
yanked: &None,
|
||||||
|
dirty: false,
|
||||||
|
search_mode: false,
|
||||||
|
formula_panel_open: false,
|
||||||
|
category_panel_open: false,
|
||||||
|
view_panel_open: false,
|
||||||
|
formula_cursor: 0,
|
||||||
|
cat_panel_cursor: 0,
|
||||||
|
view_panel_cursor: 0,
|
||||||
|
tile_cat_idx: 0,
|
||||||
|
buffers: &EMPTY_BUFFERS,
|
||||||
|
view_back_stack: &[],
|
||||||
|
view_forward_stack: &[],
|
||||||
|
has_drill_state: false,
|
||||||
|
display_value: {
|
||||||
|
let key = layout.cell_key(sr, sc);
|
||||||
|
key.as_ref()
|
||||||
|
.and_then(|k| workbook.model.get_cell(k).cloned())
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
},
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
|
expanded_cats: &EMPTY_EXPANDED,
|
||||||
|
key_code: KeyCode::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn two_cat_model() -> Workbook {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.add_category("Type").unwrap();
|
||||||
|
wb.add_category("Month").unwrap();
|
||||||
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||||
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
|
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||||
|
wb
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn three_cat_model_with_page() -> Workbook {
|
||||||
|
let mut wb = Workbook::new("Test");
|
||||||
|
wb.add_category("Type").unwrap();
|
||||||
|
wb.add_category("Month").unwrap();
|
||||||
|
wb.add_category("Region").unwrap();
|
||||||
|
wb.model.category_mut("Type").unwrap().add_item("Food");
|
||||||
|
wb.model.category_mut("Type").unwrap().add_item("Clothing");
|
||||||
|
wb.model.category_mut("Month").unwrap().add_item("Jan");
|
||||||
|
wb.model.category_mut("Month").unwrap().add_item("Feb");
|
||||||
|
wb.model.category_mut("Region").unwrap().add_item("North");
|
||||||
|
wb.model.category_mut("Region").unwrap().add_item("South");
|
||||||
|
wb.model.category_mut("Region").unwrap().add_item("East");
|
||||||
|
wb.active_view_mut()
|
||||||
|
.set_axis("Region", crate::view::Axis::Page);
|
||||||
|
wb
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
|
||||||
|
format!("{:?}", effects)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_registry() -> CmdRegistry {
|
||||||
|
default_registry()
|
||||||
|
}
|
||||||
|
}
|
||||||
354
src/command/cmd/mode.rs
Normal file
354
src/command/cmd/mode.rs
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
use super::grid::DrillIntoCell;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
use crate::workbook::Workbook;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_tile_select_with_categories() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = EnterTileSelect;
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = format!("{:?}", effects[1]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("TileSelect"),
|
||||||
|
"Expected TileSelect mode, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_tile_select_no_categories() {
|
||||||
|
let m = Workbook::new("Empty");
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = EnterTileSelect;
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_export_prompt_sets_mode() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = EnterExportPrompt.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("ExportPrompt"),
|
||||||
|
"Expected ExportPrompt mode, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_quit_always_produces_quit_mode() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.dirty = true;
|
||||||
|
let effects = ForceQuit.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Quit"), "Expected Quit mode, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_quit_produces_save_then_quit() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = SaveAndQuit.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
|
||||||
|
assert!(dbg.contains("Quit"), "Expected Quit, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_or_drill_without_aggregation_enters_edit() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = EditOrDrill {
|
||||||
|
edit_mode: AppMode::editing(),
|
||||||
|
}
|
||||||
|
.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EditOrDrill must trust its `edit_mode` parameter rather than checking
|
||||||
|
/// `ctx.mode` — the records-normal keymap supplies `records-editing`,
|
||||||
|
/// but the command itself never inspects the runtime mode. This is the
|
||||||
|
/// parallel of the (deleted) `enter_edit_mode_produces_editing_mode`
|
||||||
|
/// test for the records branch.
|
||||||
|
#[test]
|
||||||
|
fn edit_or_drill_passes_records_editing_mode_through() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
// Note: ctx.mode is still Normal here — the command must not look at it.
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = EditOrDrill {
|
||||||
|
edit_mode: AppMode::records_editing(),
|
||||||
|
}
|
||||||
|
.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("RecordsEditing"),
|
||||||
|
"Expected RecordsEditing mode, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `EnterEditAtCursorCmd` must hand its `target_mode` straight through
|
||||||
|
/// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence
|
||||||
|
/// or commit-and-advance) decides; the command never inspects ctx.
|
||||||
|
#[test]
|
||||||
|
fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = EnterEditAtCursorCmd {
|
||||||
|
target_mode: AppMode::records_editing(),
|
||||||
|
}
|
||||||
|
.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = format!("{:?}", effects[0]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("RecordsEditing"),
|
||||||
|
"Expected RecordsEditing target_mode, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edit branch pre-fills the `edit` buffer with the cell's current
|
||||||
|
/// display value so the user can modify rather than retype.
|
||||||
|
#[test]
|
||||||
|
fn edit_or_drill_pre_fills_edit_buffer_with_display_value() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.display_value = "42".to_string();
|
||||||
|
let effects = EditOrDrill {
|
||||||
|
edit_mode: AppMode::editing(),
|
||||||
|
}
|
||||||
|
.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetBuffer") && dbg.contains("\"edit\"") && dbg.contains("\"42\""),
|
||||||
|
"Expected SetBuffer(\"edit\", \"42\"), got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_search_mode_sets_flag_and_clears_query() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = EnterSearchMode.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetSearchMode(true)"),
|
||||||
|
"Expected search mode on, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetSearchQuery"),
|
||||||
|
"Expected query reset, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode change commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterMode(pub AppMode);
|
||||||
|
impl Cmd for EnterMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-mode"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
// Clear the corresponding buffer when entering a text-entry mode
|
||||||
|
if let Some(mb) = self.0.minibuffer() {
|
||||||
|
effects.push(Box::new(effect::SetBuffer {
|
||||||
|
name: mb.buffer_key.to_string(),
|
||||||
|
value: String::new(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
effects.push(effect::change_mode(self.0.clone()));
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ForceQuit;
|
||||||
|
impl Cmd for ForceQuit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"force-quit"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::change_mode(AppMode::Quit)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quit with dirty check — refuses if unsaved changes exist.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Quit;
|
||||||
|
impl Cmd for Quit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"q"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if ctx.dirty {
|
||||||
|
vec![effect::set_status(
|
||||||
|
"Unsaved changes. Use :q! to force quit or :wq to save+quit.",
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
vec![effect::change_mode(AppMode::Quit)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save then quit.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SaveAndQuit;
|
||||||
|
impl Cmd for SaveAndQuit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"wq"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::Save), effect::change_mode(AppMode::Quit)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Editing entry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
|
||||||
|
/// (categories on `Axis::None` and the cell is not a synthetic records-mode
|
||||||
|
/// row), drill into it instead of editing. Otherwise pre-fill the edit
|
||||||
|
/// buffer with the displayed cell value and enter `edit_mode`.
|
||||||
|
///
|
||||||
|
/// `edit_mode` is supplied by the keymap binding — the command itself is
|
||||||
|
/// mode-agnostic, so the records-normal keymap passes `records-editing`
|
||||||
|
/// while the normal keymap passes `editing`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EditOrDrill {
|
||||||
|
pub edit_mode: AppMode,
|
||||||
|
}
|
||||||
|
impl Cmd for EditOrDrill {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"edit-or-drill"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
// Only consider regular (non-virtual, non-label) categories on None
|
||||||
|
// as true aggregation. Virtuals like _Index/_Dim are always None in
|
||||||
|
// pivot mode and don't imply aggregation.
|
||||||
|
let regular_none = ctx.none_cats().iter().any(|c| {
|
||||||
|
ctx.model
|
||||||
|
.category(c)
|
||||||
|
.map(|cat| cat.kind.is_regular())
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
// Synthetic records-mode cells are never aggregated — edit directly.
|
||||||
|
// (This is a layout property, not a mode flag.)
|
||||||
|
let is_synthetic = ctx.synthetic_record_at_cursor().is_some();
|
||||||
|
let is_aggregated = !is_synthetic && regular_none;
|
||||||
|
if is_aggregated {
|
||||||
|
let Some(key) = ctx.cell_key().clone() else {
|
||||||
|
return vec![effect::set_status("cannot drill — no cell at cursor")];
|
||||||
|
};
|
||||||
|
return DrillIntoCell { key }.execute(ctx);
|
||||||
|
}
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetBuffer {
|
||||||
|
name: "edit".to_string(),
|
||||||
|
value: ctx.display_value.clone(),
|
||||||
|
}),
|
||||||
|
effect::change_mode(self.edit_mode.clone()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
|
||||||
|
/// participate in `Binding::Sequence`. `target_mode` is supplied as the
|
||||||
|
/// command argument by the keymap binding.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterEditAtCursorCmd {
|
||||||
|
pub target_mode: AppMode,
|
||||||
|
}
|
||||||
|
impl Cmd for EnterEditAtCursorCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-edit-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::EnterEditAtCursor {
|
||||||
|
target_mode: self.target_mode.clone(),
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter export prompt mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterExportPrompt;
|
||||||
|
impl Cmd for EnterExportPrompt {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-export-prompt"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::change_mode(AppMode::export_prompt())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter search mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterSearchMode;
|
||||||
|
impl Cmd for EnterSearchMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"search"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetSearchMode(true)),
|
||||||
|
Box::new(effect::SetSearchQuery(String::new())),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter tile select mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterTileSelect;
|
||||||
|
impl Cmd for EnterTileSelect {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-tile-select"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let count = ctx.model.category_names().len();
|
||||||
|
if count > 0 {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetTileCatIdx(0)),
|
||||||
|
effect::change_mode(AppMode::TileSelect),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
501
src/command/cmd/navigation.rs
Normal file
501
src/command/cmd/navigation.rs
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
// ── Navigation commands ──────────────────────────────────────────────────────
|
||||||
|
// All navigation commands take explicit cursor state. The interactive spec
|
||||||
|
// fills position/bounds from context; the parser accepts them as args.
|
||||||
|
|
||||||
|
/// Shared viewport state for navigation commands.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CursorState {
|
||||||
|
pub row: usize,
|
||||||
|
pub col: usize,
|
||||||
|
pub row_count: usize,
|
||||||
|
pub col_count: usize,
|
||||||
|
pub row_offset: usize,
|
||||||
|
pub col_offset: usize,
|
||||||
|
pub visible_rows: usize,
|
||||||
|
pub visible_cols: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorState {
|
||||||
|
pub fn from_ctx(ctx: &CmdContext) -> Self {
|
||||||
|
Self {
|
||||||
|
row: ctx.selected.0,
|
||||||
|
col: ctx.selected.1,
|
||||||
|
row_count: ctx.row_count(),
|
||||||
|
col_count: ctx.col_count(),
|
||||||
|
row_offset: ctx.row_offset,
|
||||||
|
col_offset: ctx.col_offset,
|
||||||
|
visible_rows: ctx.visible_rows,
|
||||||
|
visible_cols: ctx.visible_cols,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute viewport-tracking effects for a new row/col position.
|
||||||
|
pub(super) fn viewport_effects(
|
||||||
|
nr: usize,
|
||||||
|
nc: usize,
|
||||||
|
old_row_offset: usize,
|
||||||
|
old_col_offset: usize,
|
||||||
|
visible_rows: usize,
|
||||||
|
visible_cols: usize,
|
||||||
|
) -> Vec<Box<dyn Effect>> {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(nr, nc)];
|
||||||
|
let mut row_offset = old_row_offset;
|
||||||
|
let mut col_offset = old_col_offset;
|
||||||
|
let vr = visible_rows.max(1);
|
||||||
|
let vc = visible_cols.max(1);
|
||||||
|
if nr < row_offset {
|
||||||
|
row_offset = nr;
|
||||||
|
}
|
||||||
|
if nr >= row_offset + vr {
|
||||||
|
row_offset = nr.saturating_sub(vr - 1);
|
||||||
|
}
|
||||||
|
if nc < col_offset {
|
||||||
|
col_offset = nc;
|
||||||
|
}
|
||||||
|
if nc >= col_offset + vc {
|
||||||
|
col_offset = nc.saturating_sub(vc - 1);
|
||||||
|
}
|
||||||
|
if row_offset != old_row_offset {
|
||||||
|
effects.push(Box::new(effect::SetRowOffset(row_offset)));
|
||||||
|
}
|
||||||
|
if col_offset != old_col_offset {
|
||||||
|
effects.push(Box::new(effect::SetColOffset(col_offset)));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to move the cursor.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum MoveKind {
|
||||||
|
/// Relative offset (dr, dc) — subsumes MoveSelection and ScrollRows.
|
||||||
|
Relative(i32, i32),
|
||||||
|
/// Jump to start of axis: `true` = row, `false` = col.
|
||||||
|
ToStart(bool),
|
||||||
|
/// Jump to end of axis: `true` = row, `false` = col.
|
||||||
|
ToEnd(bool),
|
||||||
|
/// Page scroll: +1 = down, -1 = up (delta computed from visible_rows).
|
||||||
|
Page(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified navigation command. All variants go through `viewport_effects`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Move {
|
||||||
|
pub kind: MoveKind,
|
||||||
|
pub cursor: CursorState,
|
||||||
|
pub cmd_name: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Move {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
self.cmd_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let row_max = self.cursor.row_count.saturating_sub(1) as i32;
|
||||||
|
let col_max = self.cursor.col_count.saturating_sub(1) as i32;
|
||||||
|
let (nr, nc) = match &self.kind {
|
||||||
|
MoveKind::Relative(dr, dc) => {
|
||||||
|
let nr = (self.cursor.row as i32 + dr).clamp(0, row_max) as usize;
|
||||||
|
let nc = (self.cursor.col as i32 + dc).clamp(0, col_max) as usize;
|
||||||
|
(nr, nc)
|
||||||
|
}
|
||||||
|
MoveKind::ToStart(is_row) => {
|
||||||
|
if *is_row {
|
||||||
|
(0, self.cursor.col)
|
||||||
|
} else {
|
||||||
|
(self.cursor.row, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MoveKind::ToEnd(is_row) => {
|
||||||
|
if *is_row {
|
||||||
|
(row_max.max(0) as usize, self.cursor.col)
|
||||||
|
} else {
|
||||||
|
(self.cursor.row, col_max.max(0) as usize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MoveKind::Page(dir) => {
|
||||||
|
let delta = (self.cursor.visible_rows as i32 * 3 / 4).max(1) * dir;
|
||||||
|
let nr = (self.cursor.row as i32 + delta).clamp(0, row_max) as usize;
|
||||||
|
(nr, self.cursor.col)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viewport_effects(
|
||||||
|
nr,
|
||||||
|
nc,
|
||||||
|
self.cursor.row_offset,
|
||||||
|
self.cursor.col_offset,
|
||||||
|
self.cursor.visible_rows,
|
||||||
|
self.cursor.visible_cols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typewriter-style advance: move down, wrap to top of next column at bottom.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterAdvance {
|
||||||
|
pub cursor: CursorState,
|
||||||
|
}
|
||||||
|
impl Cmd for EnterAdvance {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-advance"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let row_max = self.cursor.row_count.saturating_sub(1);
|
||||||
|
let col_max = self.cursor.col_count.saturating_sub(1);
|
||||||
|
let (r, c) = (self.cursor.row, self.cursor.col);
|
||||||
|
let (nr, nc) = if r < row_max {
|
||||||
|
(r + 1, c)
|
||||||
|
} else if c < col_max {
|
||||||
|
(0, c + 1)
|
||||||
|
} else {
|
||||||
|
// Already at bottom-right — the advance premise no longer holds.
|
||||||
|
// Abort the rest of the batch so the caller's trailing effects
|
||||||
|
// (e.g. `CommitAndAdvance`'s `EnterEditAtCursor`) are skipped.
|
||||||
|
return vec![Box::new(effect::AbortChain)];
|
||||||
|
};
|
||||||
|
viewport_effects(
|
||||||
|
nr,
|
||||||
|
nc,
|
||||||
|
self.cursor.row_offset,
|
||||||
|
self.cursor.col_offset,
|
||||||
|
self.cursor.visible_rows,
|
||||||
|
self.cursor.visible_cols,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page navigation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Advance to the next page (odometer-style cycling).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PageNext;
|
||||||
|
impl Cmd for PageNext {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"page-next"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let data = page_cat_data(ctx);
|
||||||
|
if data.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let mut indices: Vec<usize> = data.iter().map(|(_, _, i)| *i).collect();
|
||||||
|
let mut carry = true;
|
||||||
|
for i in (0..data.len()).rev() {
|
||||||
|
if !carry {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
indices[i] += 1;
|
||||||
|
if indices[i] >= data[i].1.len() {
|
||||||
|
indices[i] = 0;
|
||||||
|
} else {
|
||||||
|
carry = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (cat, items, _))| {
|
||||||
|
Box::new(effect::SetPageSelection {
|
||||||
|
category: cat.clone(),
|
||||||
|
item: items[indices[i]].clone(),
|
||||||
|
}) as Box<dyn Effect>
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go to the previous page (odometer-style cycling).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PagePrev;
|
||||||
|
impl Cmd for PagePrev {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"page-prev"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let data = page_cat_data(ctx);
|
||||||
|
if data.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let mut indices: Vec<usize> = data.iter().map(|(_, _, i)| *i).collect();
|
||||||
|
let mut borrow = true;
|
||||||
|
for i in (0..data.len()).rev() {
|
||||||
|
if !borrow {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if indices[i] == 0 {
|
||||||
|
indices[i] = data[i].1.len().saturating_sub(1);
|
||||||
|
} else {
|
||||||
|
indices[i] -= 1;
|
||||||
|
borrow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (cat, items, _))| {
|
||||||
|
Box::new(effect::SetPageSelection {
|
||||||
|
category: cat.clone(),
|
||||||
|
item: items[indices[i]].clone(),
|
||||||
|
}) as Box<dyn Effect>
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather (cat_name, items, current_idx) for page-axis categories.
|
||||||
|
pub(super) fn page_cat_data(ctx: &CmdContext) -> Vec<(String, Vec<String>, usize)> {
|
||||||
|
let view = ctx.view;
|
||||||
|
let page_cats: Vec<String> = view
|
||||||
|
.categories_on(Axis::Page)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
page_cats
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|cat| {
|
||||||
|
let items: Vec<String> = ctx
|
||||||
|
.model
|
||||||
|
.category(&cat)
|
||||||
|
.map(|c| {
|
||||||
|
c.ordered_item_names()
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if items.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let current = view
|
||||||
|
.page_selection(&cat)
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| items.first().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let idx = items.iter().position(|i| *i == current).unwrap_or(0);
|
||||||
|
Some((cat, items, idx))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_selection_down_produces_set_selected() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = Move {
|
||||||
|
kind: MoveKind::Relative(1, 0),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_selection_clamps_to_bounds() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = Move {
|
||||||
|
kind: MoveKind::Relative(100, 100),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_advance_moves_down() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = EnterAdvance {
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
let dbg = format!("{:?}", effects[0]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetSelected(1, 0)"),
|
||||||
|
"Expected row 1, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At bottom-right `EnterAdvance` has no place to go, so it emits a
|
||||||
|
/// single `AbortChain` effect. Trailing effects in a `CommitAndAdvance`
|
||||||
|
/// batch (e.g. `EnterEditAtCursor`) are then skipped, which is how
|
||||||
|
/// "Enter at bottom-right commits and exits editing" is realised.
|
||||||
|
#[test]
|
||||||
|
fn enter_advance_at_bottom_right_emits_abort_chain() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let mut cursor = CursorState::from_ctx(&ctx);
|
||||||
|
cursor.row = cursor.row_count.saturating_sub(1);
|
||||||
|
cursor.col = cursor.col_count.saturating_sub(1);
|
||||||
|
let cmd = EnterAdvance { cursor };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1, "should emit exactly AbortChain");
|
||||||
|
let dbg = format!("{:?}", effects[0]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AbortChain"),
|
||||||
|
"Expected AbortChain, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn law_move_to_start_idempotent() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = Move {
|
||||||
|
kind: MoveKind::ToStart(true),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "jump-first-row",
|
||||||
|
};
|
||||||
|
let first = effects_debug(&cmd.execute(&ctx));
|
||||||
|
let cmd2 = Move {
|
||||||
|
kind: MoveKind::ToStart(true),
|
||||||
|
cursor: CursorState {
|
||||||
|
row: 0,
|
||||||
|
..CursorState::from_ctx(&ctx)
|
||||||
|
},
|
||||||
|
cmd_name: "jump-first-row",
|
||||||
|
};
|
||||||
|
let second = effects_debug(&cmd2.execute(&ctx));
|
||||||
|
assert_eq!(first, second, "ToStart(Row) should be idempotent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn law_sequence_associativity() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
|
||||||
|
let mk_a = || {
|
||||||
|
Move {
|
||||||
|
kind: MoveKind::Relative(1, 0),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}
|
||||||
|
.execute(&ctx)
|
||||||
|
};
|
||||||
|
let mk_b = || {
|
||||||
|
Move {
|
||||||
|
kind: MoveKind::Relative(0, 1),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}
|
||||||
|
.execute(&ctx)
|
||||||
|
};
|
||||||
|
let mk_c = || {
|
||||||
|
Move {
|
||||||
|
kind: MoveKind::ToStart(true),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "jump-first-row",
|
||||||
|
}
|
||||||
|
.execute(&ctx)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ab_c = mk_a();
|
||||||
|
ab_c.extend(mk_b());
|
||||||
|
ab_c.extend(mk_c());
|
||||||
|
|
||||||
|
let mut bc = mk_b();
|
||||||
|
bc.extend(mk_c());
|
||||||
|
let mut a_bc = mk_a();
|
||||||
|
a_bc.extend(bc);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
effects_debug(&ab_c),
|
||||||
|
effects_debug(&a_bc),
|
||||||
|
"Sequence concatenation should be associative"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn law_move_to_end_reaches_last_col() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = Move {
|
||||||
|
kind: MoveKind::ToEnd(false),
|
||||||
|
cursor: CursorState::from_ctx(&ctx),
|
||||||
|
cmd_name: "jump-last-col",
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
let expected_col = ctx.col_count().saturating_sub(1);
|
||||||
|
assert!(
|
||||||
|
dbg.contains(&format!("SetSelected(0, {expected_col})")),
|
||||||
|
"Expected jump to last col {expected_col}, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_next_with_no_page_cats_returns_empty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = PageNext.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_prev_with_no_page_cats_returns_empty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = PagePrev.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_next_cycles_through_page_items() {
|
||||||
|
let m = three_cat_model_with_page();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = PageNext.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetPageSelection"),
|
||||||
|
"Expected SetPageSelection, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_prev_cycles_backward() {
|
||||||
|
let m = three_cat_model_with_page();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = PagePrev.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetPageSelection"),
|
||||||
|
"Expected SetPageSelection, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
587
src/command/cmd/panel.rs
Normal file
587
src/command/cmd/panel.rs
Normal file
@ -0,0 +1,587 @@
|
|||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect, Panel};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
use crate::ui::effect;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_panel_open_and_focus() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = TogglePanelAndFocus {
|
||||||
|
panel: effect::Panel::Formula,
|
||||||
|
open: true,
|
||||||
|
focused: true,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = format!("{:?}", effects[1]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("FormulaPanel"),
|
||||||
|
"Expected FormulaPanel mode, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_panel_close_and_unfocus() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = TogglePanelAndFocus {
|
||||||
|
panel: effect::Panel::Formula,
|
||||||
|
open: false,
|
||||||
|
focused: false,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_panel_focus_with_no_panels_open() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = CyclePanelFocus {
|
||||||
|
formula_open: false,
|
||||||
|
category_open: false,
|
||||||
|
view_open: false,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_panel_focus_with_formula_panel_open() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.formula_panel_open = true;
|
||||||
|
let cmd = CyclePanelFocus {
|
||||||
|
formula_open: true,
|
||||||
|
category_open: false,
|
||||||
|
view_open: false,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = format!("{:?}", effects[0]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("FormulaPanel"),
|
||||||
|
"Expected FormulaPanel, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_panel_focus_with_multiple_panels() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.formula_panel_open = true;
|
||||||
|
ctx.category_panel_open = true;
|
||||||
|
let cmd = CyclePanelFocus {
|
||||||
|
formula_open: true,
|
||||||
|
category_open: true,
|
||||||
|
view_open: false,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("FormulaPanel") || dbg.contains("CategoryPanel"),
|
||||||
|
"Expected panel focus, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_panel_cursor_down_from_zero() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = MovePanelCursor {
|
||||||
|
panel: effect::Panel::Formula,
|
||||||
|
delta: 1,
|
||||||
|
current: 0,
|
||||||
|
max: 5,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetPanelCursor"),
|
||||||
|
"Expected SetPanelCursor, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_panel_cursor_clamps_at_zero() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = MovePanelCursor {
|
||||||
|
panel: effect::Panel::Formula,
|
||||||
|
delta: -1,
|
||||||
|
current: 0,
|
||||||
|
max: 5,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_panel_cursor_with_zero_max_produces_nothing() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = MovePanelCursor {
|
||||||
|
panel: effect::Panel::Formula,
|
||||||
|
delta: 1,
|
||||||
|
current: 0,
|
||||||
|
max: 0,
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_formula_at_cursor_with_formulas() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
m.model.add_formula(crate::formula::ast::Formula {
|
||||||
|
raw: "Profit = Revenue - Cost".to_string(),
|
||||||
|
target: "Profit".to_string(),
|
||||||
|
target_category: "Type".to_string(),
|
||||||
|
expr: crate::formula::ast::Expr::Number(0.0),
|
||||||
|
filter: None,
|
||||||
|
});
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = DeleteFormulaAtCursor.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("RemoveFormula"),
|
||||||
|
"Expected RemoveFormula, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_view_at_cursor_with_valid_cursor() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = SwitchViewAtCursor.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SwitchView"),
|
||||||
|
"Expected SwitchView, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switch_view_at_cursor_out_of_bounds_returns_empty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.view_panel_cursor = 999;
|
||||||
|
let effects = SwitchViewAtCursor.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_and_switch_view_names_incrementally() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = CreateAndSwitchView.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("CreateView"),
|
||||||
|
"Expected CreateView, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SwitchView"),
|
||||||
|
"Expected SwitchView, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("Normal"),
|
||||||
|
"Expected return to Normal, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_view_at_cursor_zero_does_not_adjust_cursor() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = DeleteViewAtCursor.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("DeleteView"),
|
||||||
|
"Expected DeleteView, got: {dbg}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dbg.contains("SetPanelCursor"),
|
||||||
|
"Expected no cursor adjustment at position 0, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel commands ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Toggle a panel's visibility; if it opens, focus it (enter its mode).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TogglePanelAndFocus {
|
||||||
|
pub panel: Panel,
|
||||||
|
pub open: bool,
|
||||||
|
pub focused: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for TogglePanelAndFocus {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-panel-and-focus"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
effects.push(Box::new(effect::SetPanelOpen {
|
||||||
|
panel: self.panel,
|
||||||
|
open: self.open,
|
||||||
|
}));
|
||||||
|
if self.focused {
|
||||||
|
effects.push(effect::change_mode(self.panel.mode()));
|
||||||
|
} else {
|
||||||
|
effects.push(effect::change_mode(AppMode::Normal));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a panel's visibility without changing mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TogglePanelVisibility {
|
||||||
|
pub panel: Panel,
|
||||||
|
pub currently_open: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for TogglePanelVisibility {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-panel-visibility"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SetPanelOpen {
|
||||||
|
panel: self.panel,
|
||||||
|
open: !self.currently_open,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab through open panels, entering the first open panel's mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CyclePanelFocus {
|
||||||
|
pub formula_open: bool,
|
||||||
|
pub category_open: bool,
|
||||||
|
pub view_open: bool,
|
||||||
|
}
|
||||||
|
impl Cmd for CyclePanelFocus {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"cycle-panel-focus"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if self.formula_open {
|
||||||
|
vec![effect::change_mode(AppMode::FormulaPanel)]
|
||||||
|
} else if self.category_open {
|
||||||
|
vec![effect::change_mode(AppMode::CategoryPanel)]
|
||||||
|
} else if self.view_open {
|
||||||
|
vec![effect::change_mode(AppMode::ViewPanel)]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel cursor commands ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Move a panel cursor by delta, clamping to bounds.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MovePanelCursor {
|
||||||
|
pub panel: Panel,
|
||||||
|
pub delta: i32,
|
||||||
|
pub current: usize,
|
||||||
|
pub max: usize,
|
||||||
|
}
|
||||||
|
impl Cmd for MovePanelCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"move-panel-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let cursor = self.current;
|
||||||
|
let max = self.max;
|
||||||
|
if max == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let clamped_cursor = cursor.min(max - 1);
|
||||||
|
let new = (clamped_cursor as i32 + self.delta).clamp(0, (max - 1) as i32) as usize;
|
||||||
|
if new != cursor {
|
||||||
|
vec![Box::new(effect::SetPanelCursor {
|
||||||
|
panel: self.panel,
|
||||||
|
cursor: new,
|
||||||
|
})]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formula panel commands ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Enter formula edit mode with an empty buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EnterFormulaEdit;
|
||||||
|
impl Cmd for EnterFormulaEdit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"enter-formula-edit"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![effect::change_mode(AppMode::formula_edit())]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the formula at the current cursor position.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteFormulaAtCursor;
|
||||||
|
impl Cmd for DeleteFormulaAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"delete-formula-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let formulas = ctx.model.formulas();
|
||||||
|
let cursor = ctx.formula_cursor.min(formulas.len().saturating_sub(1));
|
||||||
|
if cursor < formulas.len() {
|
||||||
|
let f = &formulas[cursor];
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||||
|
Box::new(effect::RemoveFormula {
|
||||||
|
target: f.target.clone(),
|
||||||
|
target_category: f.target_category.clone(),
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
];
|
||||||
|
if cursor > 0 {
|
||||||
|
effects.push(Box::new(effect::SetPanelCursor {
|
||||||
|
panel: Panel::Formula,
|
||||||
|
cursor: cursor - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category panel commands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Cycle the axis assignment of the category at the cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CycleAxisAtCursor;
|
||||||
|
impl Cmd for CycleAxisAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"cycle-axis-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
|
vec![Box::new(effect::CycleAxis(cat_name))]
|
||||||
|
} else {
|
||||||
|
vec![effect::set_status(
|
||||||
|
"Move cursor to a category header to change axis".to_string(),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter ItemAdd mode for the category at the panel cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OpenItemAddAtCursor;
|
||||||
|
impl Cmd for OpenItemAddAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"open-item-add-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
|
vec![effect::change_mode(AppMode::item_add(cat_name))]
|
||||||
|
} else {
|
||||||
|
vec![effect::set_status(
|
||||||
|
"No category selected. Press n to add a category first.",
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle expand/collapse of the category at the tree cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ToggleCatExpand;
|
||||||
|
impl Cmd for ToggleCatExpand {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"toggle-cat-expand"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
|
vec![Box::new(effect::ToggleCatExpand(cat_name))]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter to item: when on an item row, set the category to Page with the
|
||||||
|
/// item as the filter value.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FilterToItem;
|
||||||
|
impl Cmd for FilterToItem {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"filter-to-item"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
use crate::ui::cat_tree::CatTreeEntry;
|
||||||
|
match ctx.cat_tree_entry() {
|
||||||
|
Some(CatTreeEntry::Item {
|
||||||
|
cat_name,
|
||||||
|
item_name,
|
||||||
|
}) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetAxis {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
axis: crate::view::Axis::Page,
|
||||||
|
}),
|
||||||
|
Box::new(effect::SetPageSelection {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
item: item_name.clone(),
|
||||||
|
}),
|
||||||
|
effect::set_status(format!("Filter: {cat_name} = {item_name}")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Some(CatTreeEntry::Category { .. }) => {
|
||||||
|
// On a category header — toggle expand instead
|
||||||
|
ToggleCatExpand.execute(ctx)
|
||||||
|
}
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the category or item at the panel cursor.
|
||||||
|
/// On a category header -> delete the whole category.
|
||||||
|
/// On an item row -> delete just that item.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteCategoryAtCursor;
|
||||||
|
impl Cmd for DeleteCategoryAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"delete-category-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
use crate::ui::cat_tree::CatTreeEntry;
|
||||||
|
match ctx.cat_tree_entry() {
|
||||||
|
Some(CatTreeEntry::Category { name, .. }) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::RemoveCategory(name.clone())),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(format!("Deleted category '{name}'")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Some(CatTreeEntry::Item {
|
||||||
|
cat_name,
|
||||||
|
item_name,
|
||||||
|
}) => {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::RemoveItem {
|
||||||
|
category: cat_name.clone(),
|
||||||
|
item: item_name.clone(),
|
||||||
|
}),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(format!("Deleted item '{item_name}' from '{cat_name}'")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
None => vec![effect::set_status("No category to delete")],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View panel commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Switch to the view at the panel cursor and return to Normal mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SwitchViewAtCursor;
|
||||||
|
impl Cmd for SwitchViewAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"switch-view-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
|
||||||
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SwitchView(name.clone())),
|
||||||
|
effect::change_mode(AppMode::Normal),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new view, switch to it, and return to Normal mode.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CreateAndSwitchView;
|
||||||
|
impl Cmd for CreateAndSwitchView {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"create-and-switch-view"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let name = format!("View {}", ctx.workbook.views.len() + 1);
|
||||||
|
vec![
|
||||||
|
Box::new(effect::CreateView(name.clone())),
|
||||||
|
Box::new(effect::SwitchView(name)),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::change_mode(AppMode::Normal),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the view at the panel cursor.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DeleteViewAtCursor;
|
||||||
|
impl Cmd for DeleteViewAtCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"delete-view-at-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let view_names: Vec<String> = ctx.workbook.views.keys().cloned().collect();
|
||||||
|
if let Some(name) = view_names.get(ctx.view_panel_cursor) {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = vec![
|
||||||
|
Box::new(effect::DeleteView(name.clone())),
|
||||||
|
effect::mark_dirty(),
|
||||||
|
];
|
||||||
|
if ctx.view_panel_cursor > 0 {
|
||||||
|
effects.push(Box::new(effect::SetPanelCursor {
|
||||||
|
panel: Panel::View,
|
||||||
|
cursor: ctx.view_panel_cursor - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
601
src/command/cmd/registry.rs
Normal file
601
src/command/cmd/registry.rs
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
use crate::model::cell::CellKey;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::Panel;
|
||||||
|
|
||||||
|
/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill`
|
||||||
|
/// keymap bindings) into an `AppMode`.
|
||||||
|
fn parse_mode_name(s: &str) -> Result<AppMode, String> {
|
||||||
|
match s {
|
||||||
|
"normal" => Ok(AppMode::Normal),
|
||||||
|
"help" => Ok(AppMode::Help),
|
||||||
|
"formula-panel" => Ok(AppMode::FormulaPanel),
|
||||||
|
"category-panel" => Ok(AppMode::CategoryPanel),
|
||||||
|
"view-panel" => Ok(AppMode::ViewPanel),
|
||||||
|
"tile-select" => Ok(AppMode::TileSelect),
|
||||||
|
"command" => Ok(AppMode::command_mode()),
|
||||||
|
"category-add" => Ok(AppMode::category_add()),
|
||||||
|
"editing" => Ok(AppMode::editing()),
|
||||||
|
"records-normal" => Ok(AppMode::RecordsNormal),
|
||||||
|
"records-editing" => Ok(AppMode::records_editing()),
|
||||||
|
"formula-edit" => Ok(AppMode::formula_edit()),
|
||||||
|
"export-prompt" => Ok(AppMode::export_prompt()),
|
||||||
|
other => Err(format!("Unknown mode: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::cell::*;
|
||||||
|
use super::commit::*;
|
||||||
|
use super::core::*;
|
||||||
|
use super::effect_cmds::*;
|
||||||
|
use super::grid::*;
|
||||||
|
use super::mode::*;
|
||||||
|
use super::navigation::*;
|
||||||
|
use super::panel::*;
|
||||||
|
use super::search::*;
|
||||||
|
use super::text_buffer::*;
|
||||||
|
use super::tile::*;
|
||||||
|
|
||||||
|
/// Build the default command registry with all commands.
|
||||||
|
/// Registry names MUST match the `Cmd::name()` return value.
|
||||||
|
pub fn default_registry() -> CmdRegistry {
|
||||||
|
let mut r = CmdRegistry::new();
|
||||||
|
|
||||||
|
// ── Model mutations (effect_cmd! wrappers) ───────────────────────────
|
||||||
|
r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse);
|
||||||
|
r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse);
|
||||||
|
r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse);
|
||||||
|
r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse);
|
||||||
|
r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse);
|
||||||
|
r.register_pure(&ClearBufferCmd(vec![]), ClearBufferCmd::parse);
|
||||||
|
r.register(
|
||||||
|
&ClearCellCommand {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err("clear-cell requires at least one Cat/Item coordinate".into());
|
||||||
|
}
|
||||||
|
Ok(Box::new(ClearCellCommand {
|
||||||
|
key: parse_cell_key_from_args(args),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_args, ctx| {
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(ClearCellCommand { key }))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse);
|
||||||
|
r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse);
|
||||||
|
r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse);
|
||||||
|
r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse);
|
||||||
|
r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse);
|
||||||
|
r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse);
|
||||||
|
r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse);
|
||||||
|
r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse);
|
||||||
|
r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse);
|
||||||
|
r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse);
|
||||||
|
r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse);
|
||||||
|
r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse);
|
||||||
|
r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse);
|
||||||
|
r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse);
|
||||||
|
r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse);
|
||||||
|
r.register_pure(&ImportCmd(vec![]), ImportCmd::parse);
|
||||||
|
r.register_pure(&ExportCmd(vec![]), ExportCmd::parse);
|
||||||
|
r.register_pure(&WriteCmd(vec![]), WriteCmd::parse);
|
||||||
|
r.register_pure(&HelpCmd(vec![]), HelpCmd::parse);
|
||||||
|
r.register(
|
||||||
|
&HelpPageNextCmd(vec![]),
|
||||||
|
HelpPageNextCmd::parse,
|
||||||
|
|_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))),
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&HelpPagePrevCmd(vec![]),
|
||||||
|
HelpPagePrevCmd::parse,
|
||||||
|
|_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Navigation (unified Move) ──────────────────────────────────────
|
||||||
|
r.register(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Relative(0, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("move-selection", args, 2)?;
|
||||||
|
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(dr, dc),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("move-selection", args, 2)?;
|
||||||
|
let dr = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
let dc = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(dr, dc),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "move-selection",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Jump-to-edge commands: first/last row/col
|
||||||
|
macro_rules! reg_jump {
|
||||||
|
($r:expr, $is_row:expr, $to_end:expr, $name:expr) => {
|
||||||
|
$r.register(
|
||||||
|
&Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: $name,
|
||||||
|
},
|
||||||
|
|_| {
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: $name,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_, ctx| {
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: if $to_end {
|
||||||
|
MoveKind::ToEnd($is_row)
|
||||||
|
} else {
|
||||||
|
MoveKind::ToStart($is_row)
|
||||||
|
},
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: $name,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
reg_jump!(r, true, false, "jump-first-row");
|
||||||
|
reg_jump!(r, true, true, "jump-last-row");
|
||||||
|
reg_jump!(r, false, false, "jump-first-col");
|
||||||
|
reg_jump!(r, false, true, "jump-last-col");
|
||||||
|
r.register(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Relative(0, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("scroll-rows", args, 1)?;
|
||||||
|
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(n, 0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("scroll-rows", args, 1)?;
|
||||||
|
let n = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Relative(n, 0),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "scroll-rows",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&Move {
|
||||||
|
kind: MoveKind::Page(0),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("page-scroll", args, 1)?;
|
||||||
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Page(dir),
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("page-scroll", args, 1)?;
|
||||||
|
let dir = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(Move {
|
||||||
|
kind: MoveKind::Page(dir),
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
cmd_name: "page-scroll",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&EnterAdvance {
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
},
|
||||||
|
|_| {
|
||||||
|
Ok(Box::new(EnterAdvance {
|
||||||
|
cursor: CursorState {
|
||||||
|
row: 0,
|
||||||
|
col: 0,
|
||||||
|
row_count: 0,
|
||||||
|
col_count: 0,
|
||||||
|
row_offset: 0,
|
||||||
|
col_offset: 0,
|
||||||
|
visible_rows: 20,
|
||||||
|
visible_cols: 8,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_, ctx| {
|
||||||
|
Ok(Box::new(EnterAdvance {
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cell operations ──────────────────────────────────────────────────
|
||||||
|
r.register(
|
||||||
|
&YankCell {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err("yank requires at least one Cat/Item coordinate".into());
|
||||||
|
}
|
||||||
|
Ok(Box::new(YankCell {
|
||||||
|
key: parse_cell_key_from_args(args),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_args, ctx| {
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(YankCell { key }))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&PasteCell {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err("paste requires at least one Cat/Item coordinate".into());
|
||||||
|
}
|
||||||
|
Ok(Box::new(PasteCell {
|
||||||
|
key: parse_cell_key_from_args(args),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_args, ctx| {
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(PasteCell { key }))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// clear-cell is registered above (unified: ctx.cell_key() or explicit coords)
|
||||||
|
|
||||||
|
// ── View / page ──────────────────────────────────────────────────────
|
||||||
|
r.register_nullary(|| Box::new(TransposeAxes));
|
||||||
|
r.register_nullary(|| Box::new(PageNext));
|
||||||
|
r.register_nullary(|| Box::new(PagePrev));
|
||||||
|
|
||||||
|
// ── Mode changes ─────────────────────────────────────────────────────
|
||||||
|
r.register_nullary(|| Box::new(ForceQuit));
|
||||||
|
r.register_nullary(|| Box::new(Quit));
|
||||||
|
r.register_nullary(|| Box::new(SaveAndQuit));
|
||||||
|
r.register_nullary(|| Box::new(SaveCmd));
|
||||||
|
r.register_nullary(|| Box::new(EnterSearchMode));
|
||||||
|
r.register_pure(&NamedCmd("edit-or-drill"), |args| {
|
||||||
|
require_args("edit-or-drill", args, 1)?;
|
||||||
|
let edit_mode = parse_mode_name(&args[0])?;
|
||||||
|
Ok(Box::new(EditOrDrill { edit_mode }))
|
||||||
|
});
|
||||||
|
r.register_pure(&NamedCmd("enter-edit-at-cursor"), |args| {
|
||||||
|
require_args("enter-edit-at-cursor", args, 1)?;
|
||||||
|
let target_mode = parse_mode_name(&args[0])?;
|
||||||
|
Ok(Box::new(EnterEditAtCursorCmd { target_mode }))
|
||||||
|
});
|
||||||
|
r.register_nullary(|| Box::new(EnterExportPrompt));
|
||||||
|
r.register_nullary(|| Box::new(EnterFormulaEdit));
|
||||||
|
r.register_nullary(|| Box::new(EnterTileSelect));
|
||||||
|
r.register(
|
||||||
|
&DrillIntoCell {
|
||||||
|
key: crate::model::cell::CellKey::new(vec![]),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Err("drill-into-cell requires Cat/Item coordinates".into());
|
||||||
|
}
|
||||||
|
Ok(Box::new(DrillIntoCell {
|
||||||
|
key: parse_cell_key_from_args(args),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_args, ctx| {
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(DrillIntoCell { key }))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register_nullary(|| Box::new(ViewNavigate { forward: false }));
|
||||||
|
r.register(
|
||||||
|
&ViewNavigate { forward: true },
|
||||||
|
|_| Ok(Box::new(ViewNavigate { forward: true })),
|
||||||
|
|_, _| Ok(Box::new(ViewNavigate { forward: true })),
|
||||||
|
);
|
||||||
|
r.register_pure(&NamedCmd("enter-mode"), |args| {
|
||||||
|
require_args("enter-mode", args, 1)?;
|
||||||
|
Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search ───────────────────────────────────────────────────────────
|
||||||
|
r.register_pure(&NamedCmd("search-navigate"), |args| {
|
||||||
|
let forward = args.first().map(|s| s != "backward").unwrap_or(true);
|
||||||
|
Ok(Box::new(SearchNavigate(forward)))
|
||||||
|
});
|
||||||
|
r.register_nullary(|| Box::new(SearchOrCategoryAdd));
|
||||||
|
r.register_nullary(|| Box::new(ExitSearchMode));
|
||||||
|
|
||||||
|
// ── Panel operations ─────────────────────────────────────────────────
|
||||||
|
r.register(
|
||||||
|
&TogglePanelAndFocus {
|
||||||
|
panel: Panel::Formula,
|
||||||
|
open: true,
|
||||||
|
focused: true,
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
// Parse: toggle-panel-and-focus <panel> [open] [focused]
|
||||||
|
require_args("toggle-panel-and-focus", args, 1)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
let open = args.get(1).map(|s| s == "true").unwrap_or(true);
|
||||||
|
let focused = args.get(2).map(|s| s == "true").unwrap_or(open);
|
||||||
|
Ok(Box::new(TogglePanelAndFocus {
|
||||||
|
panel,
|
||||||
|
open,
|
||||||
|
focused,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("toggle-panel-and-focus", args, 1)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
// Default interactive: if already open+focused -> close, else open+focus
|
||||||
|
let currently_open = match panel {
|
||||||
|
Panel::Formula => ctx.formula_panel_open,
|
||||||
|
Panel::Category => ctx.category_panel_open,
|
||||||
|
Panel::View => ctx.view_panel_open,
|
||||||
|
};
|
||||||
|
let currently_focused = match panel {
|
||||||
|
Panel::Formula => matches!(
|
||||||
|
ctx.mode,
|
||||||
|
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
|
||||||
|
),
|
||||||
|
Panel::Category => matches!(
|
||||||
|
ctx.mode,
|
||||||
|
AppMode::CategoryPanel | AppMode::CategoryAdd { .. } | AppMode::ItemAdd { .. }
|
||||||
|
),
|
||||||
|
Panel::View => matches!(ctx.mode, AppMode::ViewPanel),
|
||||||
|
};
|
||||||
|
let (open, focused) = if currently_open && currently_focused {
|
||||||
|
(false, false) // close
|
||||||
|
} else {
|
||||||
|
(true, true) // open + focus
|
||||||
|
};
|
||||||
|
Ok(Box::new(TogglePanelAndFocus {
|
||||||
|
panel,
|
||||||
|
open,
|
||||||
|
focused,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&TogglePanelVisibility {
|
||||||
|
panel: Panel::Formula,
|
||||||
|
currently_open: false,
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("toggle-panel-visibility", args, 1)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
Ok(Box::new(TogglePanelVisibility {
|
||||||
|
panel,
|
||||||
|
currently_open: false,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("toggle-panel-visibility", args, 1)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
let currently_open = match panel {
|
||||||
|
Panel::Formula => ctx.formula_panel_open,
|
||||||
|
Panel::Category => ctx.category_panel_open,
|
||||||
|
Panel::View => ctx.view_panel_open,
|
||||||
|
};
|
||||||
|
Ok(Box::new(TogglePanelVisibility {
|
||||||
|
panel,
|
||||||
|
currently_open,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&CyclePanelFocus {
|
||||||
|
formula_open: false,
|
||||||
|
category_open: false,
|
||||||
|
view_open: false,
|
||||||
|
},
|
||||||
|
|_| {
|
||||||
|
Ok(Box::new(CyclePanelFocus {
|
||||||
|
formula_open: false,
|
||||||
|
category_open: false,
|
||||||
|
view_open: false,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|_, ctx| {
|
||||||
|
Ok(Box::new(CyclePanelFocus {
|
||||||
|
formula_open: ctx.formula_panel_open,
|
||||||
|
category_open: ctx.category_panel_open,
|
||||||
|
view_open: ctx.view_panel_open,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&MovePanelCursor {
|
||||||
|
panel: Panel::Formula,
|
||||||
|
delta: 0,
|
||||||
|
current: 0,
|
||||||
|
max: 0,
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
require_args("move-panel-cursor", args, 2)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(MovePanelCursor {
|
||||||
|
panel,
|
||||||
|
delta,
|
||||||
|
current: 0,
|
||||||
|
max: 0,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("move-panel-cursor", args, 2)?;
|
||||||
|
let panel = parse_panel(&args[0])?;
|
||||||
|
let delta = args[1].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
let (current, max) = match panel {
|
||||||
|
Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()),
|
||||||
|
Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()),
|
||||||
|
Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()),
|
||||||
|
};
|
||||||
|
Ok(Box::new(MovePanelCursor {
|
||||||
|
panel,
|
||||||
|
delta,
|
||||||
|
current,
|
||||||
|
max,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register_nullary(|| Box::new(DeleteFormulaAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(AddRecordRow));
|
||||||
|
r.register_nullary(|| Box::new(TogglePruneEmpty));
|
||||||
|
r.register_nullary(|| Box::new(ToggleRecordsMode));
|
||||||
|
r.register_nullary(|| Box::new(CycleAxisAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(OpenItemAddAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(DeleteCategoryAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(ToggleCatExpand));
|
||||||
|
r.register_nullary(|| Box::new(FilterToItem));
|
||||||
|
r.register_nullary(|| Box::new(SwitchViewAtCursor));
|
||||||
|
r.register_nullary(|| Box::new(CreateAndSwitchView));
|
||||||
|
r.register_nullary(|| Box::new(DeleteViewAtCursor));
|
||||||
|
|
||||||
|
// ── Tile select ──────────────────────────────────────────────────────
|
||||||
|
r.register_pure(&NamedCmd("move-tile-cursor"), |args| {
|
||||||
|
require_args("move-tile-cursor", args, 1)?;
|
||||||
|
let delta = args[0].parse::<i32>().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Box::new(MoveTileCursor(delta)))
|
||||||
|
});
|
||||||
|
r.register_nullary(|| Box::new(TileAxisOp { axis: None }));
|
||||||
|
r.register_pure(&NamedCmd("set-axis-for-tile"), |args| {
|
||||||
|
require_args("set-axis-for-tile", args, 1)?;
|
||||||
|
let axis = parse_axis(&args[0])?;
|
||||||
|
Ok(Box::new(TileAxisOp { axis: Some(axis) }))
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grid operations ──────────────────────────────────────────────────
|
||||||
|
r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true }));
|
||||||
|
r.register(
|
||||||
|
&ToggleGroupAtCursor { is_row: false },
|
||||||
|
|_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|
||||||
|
|_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })),
|
||||||
|
);
|
||||||
|
r.register_nullary(|| Box::new(HideSelectedRowItem));
|
||||||
|
|
||||||
|
// ── Text buffer ──────────────────────────────────────────────────────
|
||||||
|
r.register_pure(&NamedCmd("append-char"), |args| {
|
||||||
|
require_args("append-char", args, 1)?;
|
||||||
|
Ok(Box::new(AppendChar {
|
||||||
|
buffer: args[0].clone(),
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
r.register_pure(&NamedCmd("pop-char"), |args| {
|
||||||
|
require_args("pop-char", args, 1)?;
|
||||||
|
Ok(Box::new(PopChar {
|
||||||
|
buffer: args[0].clone(),
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
r.register_nullary(|| Box::new(CommandModeBackspace));
|
||||||
|
|
||||||
|
// ── Commit ───────────────────────────────────────────────────────────
|
||||||
|
// commit-cell-edit / commit-and-advance-right take a mode-name arg
|
||||||
|
// (e.g. "editing" or "records-editing") as args[0]. The keymap supplies
|
||||||
|
// it; the command never inspects ctx.mode.
|
||||||
|
r.register(
|
||||||
|
&CommitAndAdvance {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
value: String::new(),
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
edit_mode: AppMode::editing(),
|
||||||
|
},
|
||||||
|
|args| {
|
||||||
|
if args.len() < 3 {
|
||||||
|
return Err("commit-cell-edit requires a mode, value, and coords".into());
|
||||||
|
}
|
||||||
|
let edit_mode = parse_mode_name(&args[0])?;
|
||||||
|
Ok(Box::new(CommitAndAdvance {
|
||||||
|
key: parse_cell_key_from_args(&args[2..]),
|
||||||
|
value: args[1].clone(),
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
edit_mode,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("commit-cell-edit", args, 1)?;
|
||||||
|
let edit_mode = parse_mode_name(&args[0])?;
|
||||||
|
let value = read_buffer(ctx, "edit");
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(CommitAndAdvance {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
advance: AdvanceDir::Down,
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
edit_mode,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register(
|
||||||
|
&CommitAndAdvance {
|
||||||
|
key: CellKey::new(vec![]),
|
||||||
|
value: String::new(),
|
||||||
|
advance: AdvanceDir::Right,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
edit_mode: AppMode::editing(),
|
||||||
|
},
|
||||||
|
|_| Err("commit-and-advance-right requires context".into()),
|
||||||
|
|args, ctx| {
|
||||||
|
require_args("commit-and-advance-right", args, 1)?;
|
||||||
|
let edit_mode = parse_mode_name(&args[0])?;
|
||||||
|
let value = read_buffer(ctx, "edit");
|
||||||
|
let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
|
||||||
|
Ok(Box::new(CommitAndAdvance {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
advance: AdvanceDir::Right,
|
||||||
|
cursor: CursorState::from_ctx(ctx),
|
||||||
|
edit_mode,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
r.register_nullary(|| Box::new(CommitFormula));
|
||||||
|
r.register_nullary(|| Box::new(CommitCategoryAdd));
|
||||||
|
r.register_nullary(|| Box::new(CommitItemAdd));
|
||||||
|
r.register_nullary(|| Box::new(CommitExport));
|
||||||
|
r.register_nullary(|| Box::new(ExecuteCommand));
|
||||||
|
|
||||||
|
// ── Wizard ───────────────────────────────────────────────────────────
|
||||||
|
r.register_nullary(|| Box::new(HandleWizardKey));
|
||||||
|
|
||||||
|
// ── Aliases (short names for common commands) ────────────────────────
|
||||||
|
r.alias("add-cat", "add-category");
|
||||||
|
r.alias("formula", "add-formula");
|
||||||
|
r.alias("add-view", "create-view");
|
||||||
|
r.alias("q!", "force-quit");
|
||||||
|
|
||||||
|
r
|
||||||
|
}
|
||||||
202
src/command/cmd/search.rs
Normal file
202
src/command/cmd/search.rs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
use crate::model::cell::CellValue;
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect, Panel};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_navigate_with_empty_query_returns_nothing() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = SearchNavigate(true);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_or_category_add_without_query_opens_category_add() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = SearchOrCategoryAdd;
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 2);
|
||||||
|
let dbg = format!("{:?}", effects[1]);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("CategoryAdd"),
|
||||||
|
"Expected CategoryAdd, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_search_mode_clears_flag() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let effects = ExitSearchMode.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetSearchMode(false)"),
|
||||||
|
"Expected search mode off, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_navigate_forward_with_matching_value() {
|
||||||
|
let mut m = two_cat_model();
|
||||||
|
m.model.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("Type".into(), "Food".into()),
|
||||||
|
("Month".into(), "Jan".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(42.0),
|
||||||
|
);
|
||||||
|
m.model.set_cell(
|
||||||
|
CellKey::new(vec![
|
||||||
|
("Type".into(), "Clothing".into()),
|
||||||
|
("Month".into(), "Feb".into()),
|
||||||
|
]),
|
||||||
|
CellValue::Number(99.0),
|
||||||
|
);
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.search_query = "99";
|
||||||
|
let cmd = SearchNavigate(true);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
if !effects.is_empty() {
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetSelected"),
|
||||||
|
"Expected SetSelected, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to the next or previous search match.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchNavigate(pub bool);
|
||||||
|
impl Cmd for SearchNavigate {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"search-navigate"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let query = ctx.search_query.to_lowercase();
|
||||||
|
if query.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cur_row, cur_col) = ctx.selected;
|
||||||
|
let total_rows = ctx.row_count().max(1);
|
||||||
|
let total_cols = ctx.col_count().max(1);
|
||||||
|
let total = total_rows * total_cols;
|
||||||
|
let cur_flat = cur_row * total_cols + cur_col;
|
||||||
|
|
||||||
|
let matches: Vec<usize> = (0..total)
|
||||||
|
.filter(|&flat| {
|
||||||
|
let ri = flat / total_cols;
|
||||||
|
let ci = flat % total_cols;
|
||||||
|
let key = match ctx.layout.cell_key(ri, ci) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
||||||
|
Some(CellValue::Number(n)) => format!("{n}"),
|
||||||
|
Some(CellValue::Text(t)) => t,
|
||||||
|
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
s.to_lowercase().contains(&query)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if matches.is_empty() {
|
||||||
|
return vec![effect::set_status(format!(
|
||||||
|
"No matches for '{}'",
|
||||||
|
ctx.search_query
|
||||||
|
))];
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_flat = if self.0 {
|
||||||
|
matches
|
||||||
|
.iter()
|
||||||
|
.find(|&&f| f > cur_flat)
|
||||||
|
.or_else(|| matches.first())
|
||||||
|
.copied()
|
||||||
|
} else {
|
||||||
|
matches
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|&&f| f < cur_flat)
|
||||||
|
.or_else(|| matches.last())
|
||||||
|
.copied()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(flat) = target_flat {
|
||||||
|
let ri = flat / total_cols;
|
||||||
|
let ci = flat % total_cols;
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = vec![effect::set_selected(ri, ci)];
|
||||||
|
if ri < ctx.row_offset {
|
||||||
|
effects.push(Box::new(effect::SetRowOffset(ri)));
|
||||||
|
}
|
||||||
|
if ci < ctx.col_offset {
|
||||||
|
effects.push(Box::new(effect::SetColOffset(ci)));
|
||||||
|
}
|
||||||
|
effects.push(effect::set_status(format!(
|
||||||
|
"Match {}/{} for '{}'",
|
||||||
|
matches.iter().position(|&f| f == flat).unwrap_or(0) + 1,
|
||||||
|
matches.len(),
|
||||||
|
ctx.search_query,
|
||||||
|
)));
|
||||||
|
effects
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If search query is active, navigate backward; otherwise open CategoryAdd.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SearchOrCategoryAdd;
|
||||||
|
impl Cmd for SearchOrCategoryAdd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"search-or-category-add"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if !ctx.search_query.is_empty() {
|
||||||
|
SearchNavigate(false).execute(ctx)
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Box::new(effect::SetPanelOpen {
|
||||||
|
panel: Panel::Category,
|
||||||
|
open: true,
|
||||||
|
}),
|
||||||
|
effect::change_mode(AppMode::category_add()),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exit search mode (clears search_mode flag).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExitSearchMode;
|
||||||
|
impl Cmd for ExitSearchMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"exit-search-mode"
|
||||||
|
}
|
||||||
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::SetSearchMode(false))]
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/command/cmd/text_buffer.rs
Normal file
256
src/command/cmd/text_buffer.rs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
use crossterm::event::KeyCode;
|
||||||
|
|
||||||
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext, read_buffer};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_mode_backspace_pops_char() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "hel".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommandModeBackspace.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SetBuffer"), "Expected SetBuffer, got: {dbg}");
|
||||||
|
assert!(dbg.contains("he"), "Expected 'he' after pop, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_mode_backspace_on_empty_returns_to_normal() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = CommandModeBackspace.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("Normal"),
|
||||||
|
"Expected return to Normal, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit_when_dirty_shows_warning() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "q".to_string());
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.dirty = true;
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let cmd = ExecuteCommand;
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
let dbg = format!("{:?}", effects);
|
||||||
|
assert!(dbg.contains("SetStatus"), "Expected SetStatus, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit_when_clean_produces_quit_mode() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "q".to_string());
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let cmd = ExecuteCommand;
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(
|
||||||
|
effects.iter().any(|e| e.changes_mode()),
|
||||||
|
"Expected a mode-changing effect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_command_empty_returns_to_normal() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = ExecuteCommand.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_command_invalid_shows_error_status() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "nonexistent-command".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = ExecuteCommand.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("Normal"),
|
||||||
|
"Expected Normal mode on error, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_command_valid_runs_command() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut bufs = HashMap::new();
|
||||||
|
bufs.insert("command".to_string(), "add-category Region".to_string());
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.buffers = &bufs;
|
||||||
|
let effects = ExecuteCommand.execute(&ctx);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("AddCategory"),
|
||||||
|
"Expected AddCategory effect, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the pressed character to a named buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AppendChar {
|
||||||
|
pub buffer: String,
|
||||||
|
}
|
||||||
|
impl Cmd for AppendChar {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"append-char"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
if let KeyCode::Char(c) = ctx.key_code {
|
||||||
|
let mut val = read_buffer(ctx, &self.buffer);
|
||||||
|
val.push(c);
|
||||||
|
if self.buffer == "search" {
|
||||||
|
vec![Box::new(effect::SetSearchQuery(val))]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::SetBuffer {
|
||||||
|
name: self.buffer.clone(),
|
||||||
|
value: val,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the last character from a named buffer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PopChar {
|
||||||
|
pub buffer: String,
|
||||||
|
}
|
||||||
|
impl Cmd for PopChar {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"pop-char"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let mut val = read_buffer(ctx, &self.buffer);
|
||||||
|
val.pop();
|
||||||
|
if self.buffer == "search" {
|
||||||
|
vec![Box::new(effect::SetSearchQuery(val))]
|
||||||
|
} else {
|
||||||
|
vec![Box::new(effect::SetBuffer {
|
||||||
|
name: self.buffer.clone(),
|
||||||
|
value: val,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle backspace in command mode — pop char or return to Normal if empty.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommandModeBackspace;
|
||||||
|
impl Cmd for CommandModeBackspace {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"command-mode-backspace"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let val = ctx.buffers.get("command").cloned().unwrap_or_default();
|
||||||
|
if val.is_empty() {
|
||||||
|
vec![effect::change_mode(AppMode::Normal)]
|
||||||
|
} else {
|
||||||
|
let mut val = val;
|
||||||
|
val.pop();
|
||||||
|
vec![Box::new(effect::SetBuffer {
|
||||||
|
name: "command".to_string(),
|
||||||
|
value: val,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wizard command ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Dispatch the current key to the import wizard effect.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HandleWizardKey;
|
||||||
|
impl Cmd for HandleWizardKey {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"handle-wizard-key"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
vec![Box::new(effect::WizardKey {
|
||||||
|
key_code: ctx.key_code,
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command mode execution ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Execute the command in the "command" buffer (the `:` command line).
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// Execute the `:` command buffer by delegating to the command registry.
|
||||||
|
/// The `:` prompt is just another frontend to the scripting language —
|
||||||
|
/// same parser as `improvise script`.
|
||||||
|
pub struct ExecuteCommand;
|
||||||
|
impl Cmd for ExecuteCommand {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"execute-command"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let raw = ctx.buffers.get("command").cloned().unwrap_or_default();
|
||||||
|
let raw = raw.trim().to_string();
|
||||||
|
if raw.is_empty() {
|
||||||
|
return vec![effect::change_mode(AppMode::Normal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::command::parse::parse_line_with(ctx.registry, &raw) {
|
||||||
|
Ok(cmds) => {
|
||||||
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
|
for cmd in cmds {
|
||||||
|
effects.extend(cmd.execute(ctx));
|
||||||
|
}
|
||||||
|
// Return to Normal unless a command already changed mode
|
||||||
|
if !effects.iter().any(|e| e.changes_mode()) {
|
||||||
|
effects.push(effect::change_mode(AppMode::Normal));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
vec![
|
||||||
|
effect::set_status(format!(":{raw} — {msg}")),
|
||||||
|
effect::change_mode(AppMode::Normal),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/command/cmd/tile.rs
Normal file
160
src/command/cmd/tile.rs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
use crate::ui::effect::{self, Effect};
|
||||||
|
use crate::view::Axis;
|
||||||
|
|
||||||
|
use super::core::{Cmd, CmdContext};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::command::cmd::test_helpers::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tile_axis_cycle_produces_cycle_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = TileAxisOp { axis: None };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("CycleAxis"), "Expected CycleAxis, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tile_axis_set_produces_set_axis_effect() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = TileAxisOp {
|
||||||
|
axis: Some(crate::view::Axis::Page),
|
||||||
|
};
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(!effects.is_empty());
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(dbg.contains("SetAxis"), "Expected SetAxis, got: {dbg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tile_axis_with_out_of_bounds_cursor_returns_empty() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let mut ctx = make_ctx(&m, &layout, ®);
|
||||||
|
ctx.tile_cat_idx = 999;
|
||||||
|
let cmd = TileAxisOp { axis: None };
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert!(effects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_tile_cursor_right() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = MoveTileCursor(1);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetTileCatIdx(1)"),
|
||||||
|
"Expected idx 1, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_tile_cursor_clamps_at_start() {
|
||||||
|
let m = two_cat_model();
|
||||||
|
let layout = make_layout(&m);
|
||||||
|
let reg = make_registry();
|
||||||
|
let ctx = make_ctx(&m, &layout, ®);
|
||||||
|
let cmd = MoveTileCursor(-1);
|
||||||
|
let effects = cmd.execute(&ctx);
|
||||||
|
assert_eq!(effects.len(), 1);
|
||||||
|
let dbg = effects_debug(&effects);
|
||||||
|
assert!(
|
||||||
|
dbg.contains("SetTileCatIdx(0)"),
|
||||||
|
"Expected clamped to 0, got: {dbg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tile select commands ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Move the tile select cursor left or right.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MoveTileCursor(pub i32);
|
||||||
|
impl Cmd for MoveTileCursor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"move-tile-cursor"
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let count = ctx.model.category_names().len();
|
||||||
|
if count == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let new = (ctx.tile_cat_idx as i32 + self.0).clamp(0, (count - 1) as i32) as usize;
|
||||||
|
vec![Box::new(effect::SetTileCatIdx(new))]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle or set the axis for the category at the tile cursor.
|
||||||
|
/// Stays in TileSelect mode so the user can adjust multiple tiles.
|
||||||
|
/// `axis: None` -> cycle, `axis: Some(a)` -> set to `a`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TileAxisOp {
|
||||||
|
pub axis: Option<Axis>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axis_label(axis: Axis) -> &'static str {
|
||||||
|
match axis {
|
||||||
|
Axis::Row => "Row",
|
||||||
|
Axis::Column => "Col",
|
||||||
|
Axis::Page => "Page",
|
||||||
|
Axis::None => "None",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for TileAxisOp {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
if self.axis.is_some() {
|
||||||
|
"set-axis-for-tile"
|
||||||
|
} else {
|
||||||
|
"cycle-axis-for-tile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
|
let cat_names = ctx.model.category_names();
|
||||||
|
if let Some(name) = cat_names.get(ctx.tile_cat_idx) {
|
||||||
|
let new_axis = match self.axis {
|
||||||
|
Some(axis) => axis,
|
||||||
|
None => {
|
||||||
|
let current = ctx.view.axis_of(name);
|
||||||
|
match current {
|
||||||
|
Axis::Row => Axis::Column,
|
||||||
|
Axis::Column => Axis::Page,
|
||||||
|
Axis::Page => Axis::None,
|
||||||
|
Axis::None => Axis::Row,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let axis_effect: Box<dyn Effect> = match self.axis {
|
||||||
|
Some(axis) => Box::new(effect::SetAxis {
|
||||||
|
category: name.to_string(),
|
||||||
|
axis,
|
||||||
|
}),
|
||||||
|
None => Box::new(effect::CycleAxis(name.to_string())),
|
||||||
|
};
|
||||||
|
let status = format!("{} → {}", name, axis_label(new_axis));
|
||||||
|
vec![
|
||||||
|
axis_effect,
|
||||||
|
effect::mark_dirty(),
|
||||||
|
effect::set_status(status),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1248
src/command/keymap.rs
Normal file
1248
src/command/keymap.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,12 @@
|
|||||||
//! Command layer — all model mutations go through this layer so they can be
|
//! Command layer — all model mutations go through this layer so they can be
|
||||||
//! replayed, scripted, and tested without the TUI.
|
//! replayed, scripted, and tested without the TUI.
|
||||||
//!
|
//!
|
||||||
//! Each command is a JSON object: `{"op": "CommandName", ...args}`.
|
//! Commands are trait objects (`dyn Cmd`) that produce effects (`dyn Effect`).
|
||||||
//! The headless CLI (--cmd / --script) routes through here, and the TUI
|
//! The headless CLI (--cmd / --script) parses quasi-lisp text into effects
|
||||||
//! App also calls dispatch() for every user action that mutates state.
|
//! and applies them directly.
|
||||||
|
|
||||||
pub mod dispatch;
|
pub mod cmd;
|
||||||
pub mod types;
|
pub mod keymap;
|
||||||
|
pub mod parse;
|
||||||
|
|
||||||
pub use dispatch::dispatch;
|
pub use parse::parse_line;
|
||||||
pub use types::{Command, CommandResult};
|
|
||||||
|
|||||||
236
src/command/parse.rs
Normal file
236
src/command/parse.rs
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
//! 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::{Cmd, CmdRegistry, default_registry};
|
||||||
|
|
||||||
|
/// 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(®istry, 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alias resolution ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_add_cat_resolves_to_add_category() {
|
||||||
|
let cmds = parse_line("add-cat Region").unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "add-category");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_formula_resolves_to_add_formula() {
|
||||||
|
let cmds = parse_line(r#"formula Product "Total = A + B""#).unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "add-formula");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_add_view_resolves_to_create_view() {
|
||||||
|
let cmds = parse_line("add-view MyView").unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "create-view");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_q_bang_resolves_to_force_quit() {
|
||||||
|
let cmds = parse_line("q!").unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "force-quit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alias_does_not_interfere_with_canonical_q() {
|
||||||
|
let cmds = parse_line("q").unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "q");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── add-items command ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_add_items_multiple() {
|
||||||
|
let cmds = parse_line("add-items Region North South East").unwrap();
|
||||||
|
assert_eq!(cmds.len(), 1);
|
||||||
|
assert_eq!(cmds[0].name(), "add-items");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_items_requires_at_least_two_args() {
|
||||||
|
assert!(parse_line("add-items").is_err());
|
||||||
|
assert!(parse_line("add-items Region").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
420
src/draw.rs
Normal file
420
src/draw.rs
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
use std::io::{self, Stdout};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, Event},
|
||||||
|
execute,
|
||||||
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
Frame, Terminal,
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::ui::app::{App, AppMode};
|
||||||
|
use crate::ui::category_panel::CategoryContent;
|
||||||
|
use crate::ui::formula_panel::FormulaContent;
|
||||||
|
use crate::ui::grid::GridWidget;
|
||||||
|
use crate::ui::help::HelpWidget;
|
||||||
|
use crate::ui::import_wizard_ui::ImportWizardWidget;
|
||||||
|
use crate::ui::panel::Panel;
|
||||||
|
use crate::ui::tile_bar::TileBar;
|
||||||
|
use crate::ui::view_panel::ViewContent;
|
||||||
|
use crate::ui::which_key::WhichKeyWidget;
|
||||||
|
|
||||||
|
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(
|
||||||
|
workbook: crate::workbook::Workbook,
|
||||||
|
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(workbook, file_path);
|
||||||
|
|
||||||
|
if let Some(json) = import_value {
|
||||||
|
app.start_import_wizard(json);
|
||||||
|
} else if app.is_empty_model() {
|
||||||
|
app.mode = AppMode::Help;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tui_context.terminal.draw(|f| draw(f, &app))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) => {
|
||||||
|
app.handle_key(key)?;
|
||||||
|
}
|
||||||
|
Event::Resize(w, h) => {
|
||||||
|
app.term_width = w;
|
||||||
|
app.term_height = h;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
AppMode::RecordsNormal => "RECORDS",
|
||||||
|
AppMode::RecordsEditing { .. } => "RECORDS INSERT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_style(mode: &AppMode) -> Style {
|
||||||
|
match mode {
|
||||||
|
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => {
|
||||||
|
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::new(app.help_page), size);
|
||||||
|
}
|
||||||
|
if matches!(app.mode, AppMode::ImportWizard)
|
||||||
|
&& let Some(wizard) = &app.wizard
|
||||||
|
{
|
||||||
|
f.render_widget(ImportWizardWidget::new(wizard), size);
|
||||||
|
}
|
||||||
|
// ExportPrompt now uses the minibuffer at the bottom bar.
|
||||||
|
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||||
|
draw_welcome(f, main_chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which-key popup: show available completions after a prefix key
|
||||||
|
if let Some(ref km) = app.transient_keymap {
|
||||||
|
let hints = km.binding_hints();
|
||||||
|
f.render_widget(WhichKeyWidget::new(&hints), size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.workbook.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);
|
||||||
|
let content = FormulaContent::new(&app.workbook.model, &app.mode);
|
||||||
|
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
|
||||||
|
y += ph;
|
||||||
|
}
|
||||||
|
if app.category_panel_open {
|
||||||
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
|
let content = CategoryContent::new(
|
||||||
|
&app.workbook.model,
|
||||||
|
app.workbook.active_view(),
|
||||||
|
&app.expanded_cats,
|
||||||
|
);
|
||||||
|
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
|
||||||
|
y += ph;
|
||||||
|
}
|
||||||
|
if app.view_panel_open {
|
||||||
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
|
let content = ViewContent::new(&app.workbook);
|
||||||
|
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
grid_area = area;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
GridWidget::new(
|
||||||
|
&app.workbook.model,
|
||||||
|
app.workbook.active_view(),
|
||||||
|
&app.workbook.active_view,
|
||||||
|
&app.layout,
|
||||||
|
&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.workbook.model,
|
||||||
|
app.workbook.active_view(),
|
||||||
|
&app.mode,
|
||||||
|
app.tile_cat_idx,
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
if let Some(mb) = app.mode.minibuffer() {
|
||||||
|
let buf = app
|
||||||
|
.buffers
|
||||||
|
.get(mb.buffer_key)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let text = format!("{}{}▌", mb.prompt, buf);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(text).style(
|
||||||
|
Style::default()
|
||||||
|
.fg(mb.color)
|
||||||
|
.bg(Color::Indexed(235))
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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.workbook.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_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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,461 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
|
|
||||||
use super::ast::{AggFunc, BinOp, Expr, Filter, Formula};
|
|
||||||
|
|
||||||
/// Parse a formula string like "Profit = Revenue - Cost"
|
|
||||||
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
|
|
||||||
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
|
|
||||||
let raw = raw.trim();
|
|
||||||
|
|
||||||
// Split on first `=` to get target = expression
|
|
||||||
let eq_pos = raw
|
|
||||||
.find('=')
|
|
||||||
.ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
|
|
||||||
let target = raw[..eq_pos].trim().to_string();
|
|
||||||
let rest = raw[eq_pos + 1..].trim();
|
|
||||||
|
|
||||||
// Check for WHERE clause at top level
|
|
||||||
let (expr_str, filter) = split_where(rest);
|
|
||||||
let filter = filter.map(|w| parse_where(w)).transpose()?;
|
|
||||||
|
|
||||||
let expr = parse_expr(expr_str.trim())?;
|
|
||||||
|
|
||||||
Ok(Formula::new(raw, target, target_category, expr, filter))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_where(s: &str) -> (&str, Option<&str>) {
|
|
||||||
// Find WHERE not inside parens or quotes
|
|
||||||
let bytes = s.as_bytes();
|
|
||||||
let mut depth = 0i32;
|
|
||||||
let mut i = 0;
|
|
||||||
while i < bytes.len() {
|
|
||||||
match bytes[i] {
|
|
||||||
b'(' => depth += 1,
|
|
||||||
b')' => depth -= 1,
|
|
||||||
b'"' => {
|
|
||||||
i += 1;
|
|
||||||
while i < bytes.len() && bytes[i] != b'"' {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ if depth == 0 => {
|
|
||||||
if s[i..].to_ascii_uppercase().starts_with("WHERE") {
|
|
||||||
let before = &s[..i];
|
|
||||||
let after = &s[i + 5..];
|
|
||||||
if before.ends_with(char::is_whitespace) || i == 0 {
|
|
||||||
return (before.trim(), Some(after.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
(s, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_where(s: &str) -> Result<Filter> {
|
|
||||||
// Format: Category = "Item" or Category = Item
|
|
||||||
let eq_pos = s
|
|
||||||
.find('=')
|
|
||||||
.ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
|
|
||||||
let category = s[..eq_pos].trim().to_string();
|
|
||||||
let item_raw = s[eq_pos + 1..].trim();
|
|
||||||
let item = item_raw.trim_matches('"').to_string();
|
|
||||||
Ok(Filter { category, item })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse an expression using recursive descent
|
|
||||||
pub fn parse_expr(s: &str) -> Result<Expr> {
|
|
||||||
let tokens = tokenize(s)?;
|
|
||||||
let mut pos = 0;
|
|
||||||
let expr = parse_add_sub(&tokens, &mut pos)?;
|
|
||||||
if pos < tokens.len() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Unexpected token at position {pos}: {:?}",
|
|
||||||
tokens[pos]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(expr)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
enum Token {
|
|
||||||
Number(f64),
|
|
||||||
Ident(String),
|
|
||||||
Str(String),
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
Star,
|
|
||||||
Slash,
|
|
||||||
Caret,
|
|
||||||
LParen,
|
|
||||||
RParen,
|
|
||||||
Comma,
|
|
||||||
Eq,
|
|
||||||
Ne,
|
|
||||||
Lt,
|
|
||||||
Gt,
|
|
||||||
Le,
|
|
||||||
Ge,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tokenize(s: &str) -> Result<Vec<Token>> {
|
|
||||||
let mut tokens = Vec::new();
|
|
||||||
let chars: Vec<char> = s.chars().collect();
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < chars.len() {
|
|
||||||
match chars[i] {
|
|
||||||
' ' | '\t' | '\n' => i += 1,
|
|
||||||
'+' => {
|
|
||||||
tokens.push(Token::Plus);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'-' => {
|
|
||||||
tokens.push(Token::Minus);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'*' => {
|
|
||||||
tokens.push(Token::Star);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'/' => {
|
|
||||||
tokens.push(Token::Slash);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'^' => {
|
|
||||||
tokens.push(Token::Caret);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'(' => {
|
|
||||||
tokens.push(Token::LParen);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
')' => {
|
|
||||||
tokens.push(Token::RParen);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
',' => {
|
|
||||||
tokens.push(Token::Comma);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'!' if chars.get(i + 1) == Some(&'=') => {
|
|
||||||
tokens.push(Token::Ne);
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
'<' if chars.get(i + 1) == Some(&'=') => {
|
|
||||||
tokens.push(Token::Le);
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
'>' if chars.get(i + 1) == Some(&'=') => {
|
|
||||||
tokens.push(Token::Ge);
|
|
||||||
i += 2;
|
|
||||||
}
|
|
||||||
'<' => {
|
|
||||||
tokens.push(Token::Lt);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'>' => {
|
|
||||||
tokens.push(Token::Gt);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'=' => {
|
|
||||||
tokens.push(Token::Eq);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
'"' => {
|
|
||||||
i += 1;
|
|
||||||
let mut s = String::new();
|
|
||||||
while i < chars.len() && chars[i] != '"' {
|
|
||||||
s.push(chars[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
if i < chars.len() {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
tokens.push(Token::Str(s));
|
|
||||||
}
|
|
||||||
c if c.is_ascii_digit() || c == '.' => {
|
|
||||||
let mut num = String::new();
|
|
||||||
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
|
|
||||||
num.push(chars[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
tokens.push(Token::Number(num.parse()?));
|
|
||||||
}
|
|
||||||
c if c.is_alphabetic() || c == '_' => {
|
|
||||||
let mut ident = String::new();
|
|
||||||
while i < chars.len()
|
|
||||||
&& (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ')
|
|
||||||
{
|
|
||||||
// Don't consume trailing spaces if next non-space is operator
|
|
||||||
if chars[i] == ' ' {
|
|
||||||
// Peek ahead
|
|
||||||
let j = i + 1;
|
|
||||||
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
|
|
||||||
if matches!(
|
|
||||||
next_nonspace,
|
|
||||||
Some('+')
|
|
||||||
| Some('-')
|
|
||||||
| Some('*')
|
|
||||||
| Some('/')
|
|
||||||
| Some('^')
|
|
||||||
| Some(')')
|
|
||||||
| Some(',')
|
|
||||||
| None
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ident.push(chars[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
let ident = ident.trim_end().to_string();
|
|
||||||
tokens.push(Token::Ident(ident));
|
|
||||||
}
|
|
||||||
c => return Err(anyhow!("Unexpected character '{c}' in expression")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
let mut left = parse_mul_div(tokens, pos)?;
|
|
||||||
while *pos < tokens.len() {
|
|
||||||
let op = match &tokens[*pos] {
|
|
||||||
Token::Plus => BinOp::Add,
|
|
||||||
Token::Minus => BinOp::Sub,
|
|
||||||
_ => break,
|
|
||||||
};
|
|
||||||
*pos += 1;
|
|
||||||
let right = parse_mul_div(tokens, pos)?;
|
|
||||||
left = Expr::BinOp(op, Box::new(left), Box::new(right));
|
|
||||||
}
|
|
||||||
Ok(left)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
let mut left = parse_pow(tokens, pos)?;
|
|
||||||
while *pos < tokens.len() {
|
|
||||||
let op = match &tokens[*pos] {
|
|
||||||
Token::Star => BinOp::Mul,
|
|
||||||
Token::Slash => BinOp::Div,
|
|
||||||
_ => break,
|
|
||||||
};
|
|
||||||
*pos += 1;
|
|
||||||
let right = parse_pow(tokens, pos)?;
|
|
||||||
left = Expr::BinOp(op, Box::new(left), Box::new(right));
|
|
||||||
}
|
|
||||||
Ok(left)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
let base = parse_unary(tokens, pos)?;
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::Caret {
|
|
||||||
*pos += 1;
|
|
||||||
let exp = parse_unary(tokens, pos)?;
|
|
||||||
return Ok(Expr::BinOp(BinOp::Pow, Box::new(base), Box::new(exp)));
|
|
||||||
}
|
|
||||||
Ok(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::Minus {
|
|
||||||
*pos += 1;
|
|
||||||
let e = parse_primary(tokens, pos)?;
|
|
||||||
return Ok(Expr::UnaryMinus(Box::new(e)));
|
|
||||||
}
|
|
||||||
parse_primary(tokens, pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
if *pos >= tokens.len() {
|
|
||||||
return Err(anyhow!("Unexpected end of expression"));
|
|
||||||
}
|
|
||||||
match &tokens[*pos].clone() {
|
|
||||||
Token::Number(n) => {
|
|
||||||
*pos += 1;
|
|
||||||
Ok(Expr::Number(*n))
|
|
||||||
}
|
|
||||||
Token::Ident(name) => {
|
|
||||||
let name = name.clone();
|
|
||||||
*pos += 1;
|
|
||||||
// Check for function call
|
|
||||||
let upper = name.to_ascii_uppercase();
|
|
||||||
match upper.as_str() {
|
|
||||||
"SUM" | "AVG" | "MIN" | "MAX" | "COUNT" => {
|
|
||||||
let func = match upper.as_str() {
|
|
||||||
"SUM" => AggFunc::Sum,
|
|
||||||
"AVG" => AggFunc::Avg,
|
|
||||||
"MIN" => AggFunc::Min,
|
|
||||||
"MAX" => AggFunc::Max,
|
|
||||||
"COUNT" => AggFunc::Count,
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
|
|
||||||
*pos += 1;
|
|
||||||
let inner = parse_add_sub(tokens, pos)?;
|
|
||||||
// Optional WHERE filter
|
|
||||||
let filter = if *pos < tokens.len() {
|
|
||||||
if let Token::Ident(kw) = &tokens[*pos] {
|
|
||||||
if kw.to_ascii_uppercase() == "WHERE" {
|
|
||||||
*pos += 1;
|
|
||||||
let cat = match &tokens[*pos] {
|
|
||||||
Token::Ident(s) => {
|
|
||||||
let s = s.clone();
|
|
||||||
*pos += 1;
|
|
||||||
s
|
|
||||||
}
|
|
||||||
t => {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"Expected category name, got {t:?}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// expect =
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::Eq {
|
|
||||||
*pos += 1;
|
|
||||||
}
|
|
||||||
let item = match &tokens[*pos] {
|
|
||||||
Token::Str(s) | Token::Ident(s) => {
|
|
||||||
let s = s.clone();
|
|
||||||
*pos += 1;
|
|
||||||
s
|
|
||||||
}
|
|
||||||
t => return Err(anyhow!("Expected item name, got {t:?}")),
|
|
||||||
};
|
|
||||||
Some(Filter {
|
|
||||||
category: cat,
|
|
||||||
item,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
// expect )
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
|
|
||||||
*pos += 1;
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Expected ')' to close aggregate function"));
|
|
||||||
}
|
|
||||||
return Ok(Expr::Agg(func, Box::new(inner), filter));
|
|
||||||
}
|
|
||||||
Ok(Expr::Ref(name))
|
|
||||||
}
|
|
||||||
"IF" => {
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
|
|
||||||
*pos += 1;
|
|
||||||
let cond = parse_comparison(tokens, pos)?;
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
|
|
||||||
*pos += 1;
|
|
||||||
}
|
|
||||||
let then = parse_add_sub(tokens, pos)?;
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::Comma {
|
|
||||||
*pos += 1;
|
|
||||||
}
|
|
||||||
let else_ = parse_add_sub(tokens, pos)?;
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
|
|
||||||
*pos += 1;
|
|
||||||
} else {
|
|
||||||
return Err(anyhow!("Expected ')' to close IF(...)"));
|
|
||||||
}
|
|
||||||
return Ok(Expr::If(Box::new(cond), Box::new(then), Box::new(else_)));
|
|
||||||
}
|
|
||||||
Ok(Expr::Ref(name))
|
|
||||||
}
|
|
||||||
_ => Ok(Expr::Ref(name)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Token::LParen => {
|
|
||||||
*pos += 1;
|
|
||||||
let e = parse_add_sub(tokens, pos)?;
|
|
||||||
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
|
|
||||||
*pos += 1;
|
|
||||||
}
|
|
||||||
Ok(e)
|
|
||||||
}
|
|
||||||
t => Err(anyhow!("Unexpected token in expression: {t:?}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
|
|
||||||
let left = parse_add_sub(tokens, pos)?;
|
|
||||||
if *pos >= tokens.len() {
|
|
||||||
return Ok(left);
|
|
||||||
}
|
|
||||||
let op = match &tokens[*pos] {
|
|
||||||
Token::Eq => BinOp::Eq,
|
|
||||||
Token::Ne => BinOp::Ne,
|
|
||||||
Token::Lt => BinOp::Lt,
|
|
||||||
Token::Gt => BinOp::Gt,
|
|
||||||
Token::Le => BinOp::Le,
|
|
||||||
Token::Ge => BinOp::Ge,
|
|
||||||
_ => return Ok(left),
|
|
||||||
};
|
|
||||||
*pos += 1;
|
|
||||||
let right = parse_add_sub(tokens, pos)?;
|
|
||||||
Ok(Expr::BinOp(op, Box::new(left), Box::new(right)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::parse_formula;
|
|
||||||
use crate::formula::{AggFunc, BinOp, Expr};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_simple_subtraction() {
|
|
||||||
let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap();
|
|
||||||
assert_eq!(f.target, "Profit");
|
|
||||||
assert_eq!(f.target_category, "Measure");
|
|
||||||
assert!(matches!(f.expr, Expr::BinOp(BinOp::Sub, _, _)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_where_clause() {
|
|
||||||
let f = parse_formula("EastRev = Revenue WHERE Region = \"East\"", "Measure").unwrap();
|
|
||||||
assert_eq!(f.target, "EastRev");
|
|
||||||
let filter = f.filter.as_ref().unwrap();
|
|
||||||
assert_eq!(filter.category, "Region");
|
|
||||||
assert_eq!(filter.item, "East");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_sum_aggregation() {
|
|
||||||
let f = parse_formula("Total = SUM(Revenue)", "Measure").unwrap();
|
|
||||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_avg_aggregation() {
|
|
||||||
let f = parse_formula("Avg = AVG(Revenue)", "Measure").unwrap();
|
|
||||||
assert!(matches!(f.expr, Expr::Agg(AggFunc::Avg, _, _)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_if_expression() {
|
|
||||||
let f = parse_formula("Capped = IF(Revenue > 1000, 1000, Revenue)", "Measure").unwrap();
|
|
||||||
assert!(matches!(f.expr, Expr::If(_, _, _)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_numeric_literal() {
|
|
||||||
let f = parse_formula("Fixed = 42", "Measure").unwrap();
|
|
||||||
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_chained_arithmetic() {
|
|
||||||
parse_formula("X = (A + B) * (C - D)", "Cat").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_missing_equals_returns_error() {
|
|
||||||
assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum FieldKind {
|
|
||||||
/// Small number of distinct string values → dimension/category
|
|
||||||
Category,
|
|
||||||
/// Numeric values → measure
|
|
||||||
Measure,
|
|
||||||
/// Date/time strings → time category
|
|
||||||
TimeCategory,
|
|
||||||
/// Many unique strings (IDs, names) → label/identifier
|
|
||||||
Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FieldProposal {
|
|
||||||
pub field: String,
|
|
||||||
pub kind: FieldKind,
|
|
||||||
pub distinct_values: Vec<String>,
|
|
||||||
pub accepted: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FieldProposal {
|
|
||||||
pub fn kind_label(&self) -> &'static str {
|
|
||||||
match self.kind {
|
|
||||||
FieldKind::Category => "Category (dimension)",
|
|
||||||
FieldKind::Measure => "Measure (numeric)",
|
|
||||||
FieldKind::TimeCategory => "Time Category",
|
|
||||||
FieldKind::Label => "Label/Identifier (skip)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_THRESHOLD: usize = 20;
|
|
||||||
|
|
||||||
pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
|
|
||||||
if records.is_empty() {
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all field names
|
|
||||||
let mut fields: Vec<String> = Vec::new();
|
|
||||||
for record in records {
|
|
||||||
if let Value::Object(map) = record {
|
|
||||||
for key in map.keys() {
|
|
||||||
if !fields.contains(key) {
|
|
||||||
fields.push(key.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields
|
|
||||||
.into_iter()
|
|
||||||
.map(|field| {
|
|
||||||
let values: Vec<&Value> = records.iter().filter_map(|r| r.get(&field)).collect();
|
|
||||||
|
|
||||||
let all_numeric = values.iter().all(|v| v.is_number());
|
|
||||||
let all_string = values.iter().all(|v| v.is_string());
|
|
||||||
|
|
||||||
if all_numeric {
|
|
||||||
return FieldProposal {
|
|
||||||
field,
|
|
||||||
kind: FieldKind::Measure,
|
|
||||||
distinct_values: vec![],
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if all_string {
|
|
||||||
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))
|
|
||||||
});
|
|
||||||
|
|
||||||
if looks_like_date {
|
|
||||||
return FieldProposal {
|
|
||||||
field,
|
|
||||||
kind: FieldKind::TimeCategory,
|
|
||||||
distinct_values: distinct_vec,
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if n <= CATEGORY_THRESHOLD {
|
|
||||||
return FieldProposal {
|
|
||||||
field,
|
|
||||||
kind: FieldKind::Category,
|
|
||||||
distinct_values: distinct_vec,
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return FieldProposal {
|
|
||||||
field,
|
|
||||||
kind: FieldKind::Label,
|
|
||||||
distinct_values: distinct_vec,
|
|
||||||
accepted: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixed or other: treat as label
|
|
||||||
FieldProposal {
|
|
||||||
field,
|
|
||||||
kind: FieldKind::Label,
|
|
||||||
distinct_values: vec![],
|
|
||||||
accepted: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract nested array from JSON by dot-path
|
|
||||||
pub fn extract_array_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Vec<Value>> {
|
|
||||||
if path.is_empty() {
|
|
||||||
return value.as_array();
|
|
||||||
}
|
|
||||||
let mut current = value;
|
|
||||||
for part in path.split('.') {
|
|
||||||
current = current.get(part)?;
|
|
||||||
}
|
|
||||||
current.as_array()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find candidate paths to arrays in JSON
|
|
||||||
pub fn find_array_paths(value: &Value) -> Vec<String> {
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
find_array_paths_inner(value, "", &mut paths);
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>) {
|
|
||||||
match value {
|
|
||||||
Value::Array(_) => {
|
|
||||||
paths.push(prefix.to_string());
|
|
||||||
}
|
|
||||||
Value::Object(map) => {
|
|
||||||
for (key, val) in map {
|
|
||||||
let path = if prefix.is_empty() {
|
|
||||||
key.clone()
|
|
||||||
} else {
|
|
||||||
format!("{prefix}.{key}")
|
|
||||||
};
|
|
||||||
find_array_paths_inner(val, &path, paths);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,413 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use super::analyzer::{
|
|
||||||
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
|
|
||||||
};
|
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
|
||||||
use crate::model::Model;
|
|
||||||
|
|
||||||
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Pure data + logic for turning a JSON value into a Model.
|
|
||||||
/// No cursor, no display messages — those live in [`ImportWizard`].
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ImportPipeline {
|
|
||||||
pub raw: Value,
|
|
||||||
pub array_paths: Vec<String>,
|
|
||||||
pub selected_path: String,
|
|
||||||
pub records: Vec<Value>,
|
|
||||||
pub proposals: Vec<FieldProposal>,
|
|
||||||
pub model_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportPipeline {
|
|
||||||
pub fn new(raw: Value) -> Self {
|
|
||||||
let array_paths = find_array_paths(&raw);
|
|
||||||
let mut pipeline = Self {
|
|
||||||
raw: raw.clone(),
|
|
||||||
array_paths,
|
|
||||||
selected_path: String::new(),
|
|
||||||
records: vec![],
|
|
||||||
proposals: vec![],
|
|
||||||
model_name: "Imported Model".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-select if root is an array or there is exactly one candidate path.
|
|
||||||
if raw.is_array() {
|
|
||||||
pipeline.select_path("");
|
|
||||||
} else if pipeline.array_paths.len() == 1 {
|
|
||||||
let path = pipeline.array_paths[0].clone();
|
|
||||||
pipeline.select_path(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
pipeline
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_path(&mut self, path: &str) {
|
|
||||||
self.selected_path = path.to_string();
|
|
||||||
if let Some(arr) = extract_array_at_path(&self.raw, path) {
|
|
||||||
self.records = arr.clone();
|
|
||||||
self.proposals = analyze_records(&self.records);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn needs_path_selection(&self) -> bool {
|
|
||||||
self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn preview_summary(&self) -> String {
|
|
||||||
match &self.raw {
|
|
||||||
Value::Array(arr) => format!(
|
|
||||||
"Array of {} records. Sample keys: {}",
|
|
||||||
arr.len(),
|
|
||||||
arr.first()
|
|
||||||
.and_then(|r| r.as_object())
|
|
||||||
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
|
|
||||||
.unwrap_or_default()
|
|
||||||
),
|
|
||||||
Value::Object(map) => format!(
|
|
||||||
"Object with {} top-level keys: {}",
|
|
||||||
map.len(),
|
|
||||||
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
|
|
||||||
),
|
|
||||||
_ => "Unknown JSON structure".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a Model from the current proposals. Pure — no side effects.
|
|
||||||
pub fn build_model(&self) -> Result<Model> {
|
|
||||||
let categories: Vec<&FieldProposal> = self
|
|
||||||
.proposals
|
|
||||||
.iter()
|
|
||||||
.filter(|p| {
|
|
||||||
p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let measures: Vec<&FieldProposal> = self
|
|
||||||
.proposals
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if categories.is_empty() {
|
|
||||||
return Err(anyhow!("At least one category must be accepted"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut model = Model::new(&self.model_name);
|
|
||||||
|
|
||||||
for cat_proposal in &categories {
|
|
||||||
model.add_category(&cat_proposal.field)?;
|
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
|
||||||
for val in &cat_proposal.distinct_values {
|
|
||||||
cat.add_item(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !measures.is_empty() {
|
|
||||||
model.add_category("Measure")?;
|
|
||||||
if let Some(cat) = model.category_mut("Measure") {
|
|
||||||
for m in &measures {
|
|
||||||
cat.add_item(&m.field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for record in &self.records {
|
|
||||||
if let Value::Object(map) = record {
|
|
||||||
let mut coords: Vec<(String, String)> = vec![];
|
|
||||||
let mut valid = true;
|
|
||||||
|
|
||||||
for cat_proposal in &categories {
|
|
||||||
let val = map
|
|
||||||
.get(&cat_proposal.field)
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
|
||||||
|
|
||||||
if let Some(v) = val {
|
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
|
||||||
cat.add_item(&v);
|
|
||||||
}
|
|
||||||
coords.push((cat_proposal.field.clone(), v));
|
|
||||||
} else {
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for measure in &measures {
|
|
||||||
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
|
||||||
let mut cell_coords = coords.clone();
|
|
||||||
cell_coords.push(("Measure".to_string(), measure.field.clone()));
|
|
||||||
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Wizard (UI state wrapped around the pipeline) ─────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum WizardStep {
|
|
||||||
Preview,
|
|
||||||
SelectArrayPath,
|
|
||||||
ReviewProposals,
|
|
||||||
NameModel,
|
|
||||||
Done,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard.
|
|
||||||
/// The pipeline holds all data; the wizard holds only what the UI needs to
|
|
||||||
/// drive the multi-step interaction (current step, list cursor, error message).
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ImportWizard {
|
|
||||||
pub pipeline: ImportPipeline,
|
|
||||||
pub step: WizardStep,
|
|
||||||
/// Cursor within the current list (array paths or proposals).
|
|
||||||
pub cursor: usize,
|
|
||||||
/// One-line message to display at the bottom of the wizard panel.
|
|
||||||
pub message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportWizard {
|
|
||||||
pub fn new(raw: Value) -> Self {
|
|
||||||
let pipeline = ImportPipeline::new(raw);
|
|
||||||
|
|
||||||
let step = if pipeline.needs_path_selection() {
|
|
||||||
WizardStep::SelectArrayPath
|
|
||||||
} else if pipeline.records.is_empty() {
|
|
||||||
WizardStep::Preview
|
|
||||||
} else {
|
|
||||||
WizardStep::ReviewProposals
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
pipeline,
|
|
||||||
step,
|
|
||||||
cursor: 0,
|
|
||||||
message: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step transitions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn advance(&mut self) {
|
|
||||||
self.step = match self.step {
|
|
||||||
WizardStep::Preview => {
|
|
||||||
if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
|
|
||||||
WizardStep::SelectArrayPath
|
|
||||||
} else {
|
|
||||||
WizardStep::ReviewProposals
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
|
|
||||||
WizardStep::ReviewProposals => WizardStep::NameModel,
|
|
||||||
WizardStep::NameModel => WizardStep::Done,
|
|
||||||
WizardStep::Done => WizardStep::Done,
|
|
||||||
};
|
|
||||||
self.cursor = 0;
|
|
||||||
self.message = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn confirm_path(&mut self) {
|
|
||||||
if self.cursor < self.pipeline.array_paths.len() {
|
|
||||||
let path = self.pipeline.array_paths[self.cursor].clone();
|
|
||||||
self.pipeline.select_path(&path);
|
|
||||||
self.advance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cursor movement ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn move_cursor(&mut self, delta: i32) {
|
|
||||||
let len = match self.step {
|
|
||||||
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
|
|
||||||
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
if len == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if delta > 0 {
|
|
||||||
self.cursor = (self.cursor + 1).min(len - 1);
|
|
||||||
} else if self.cursor > 0 {
|
|
||||||
self.cursor -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Proposal editing (delegates to pipeline) ──────────────────────────────
|
|
||||||
|
|
||||||
pub fn toggle_proposal(&mut self) {
|
|
||||||
if self.cursor < self.pipeline.proposals.len() {
|
|
||||||
self.pipeline.proposals[self.cursor].accepted =
|
|
||||||
!self.pipeline.proposals[self.cursor].accepted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cycle_proposal_kind(&mut self) {
|
|
||||||
if self.cursor < self.pipeline.proposals.len() {
|
|
||||||
let p = &mut self.pipeline.proposals[self.cursor];
|
|
||||||
p.kind = match p.kind {
|
|
||||||
FieldKind::Category => FieldKind::Measure,
|
|
||||||
FieldKind::Measure => FieldKind::TimeCategory,
|
|
||||||
FieldKind::TimeCategory => FieldKind::Label,
|
|
||||||
FieldKind::Label => FieldKind::Category,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Model name input ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn push_name_char(&mut self, c: char) {
|
|
||||||
self.pipeline.model_name.push(c);
|
|
||||||
}
|
|
||||||
pub fn pop_name_char(&mut self) {
|
|
||||||
self.pipeline.model_name.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delegate build to pipeline ────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn build_model(&self) -> Result<Model> {
|
|
||||||
self.pipeline.build_model()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::ImportPipeline;
|
|
||||||
use crate::import::analyzer::FieldKind;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flat_array_auto_selected() {
|
|
||||||
let raw = json!([
|
|
||||||
{"region": "East", "product": "Shirts", "revenue": 100.0},
|
|
||||||
{"region": "West", "product": "Shirts", "revenue": 200.0},
|
|
||||||
]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
assert!(!p.records.is_empty());
|
|
||||||
assert!(!p.proposals.is_empty());
|
|
||||||
assert!(!p.needs_path_selection());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn numeric_field_proposed_as_measure() {
|
|
||||||
let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap();
|
|
||||||
assert_eq!(revenue.kind, FieldKind::Measure);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn low_cardinality_string_field_proposed_as_category() {
|
|
||||||
let raw = json!([
|
|
||||||
{"region": "East", "revenue": 100.0},
|
|
||||||
{"region": "West", "revenue": 200.0},
|
|
||||||
{"region": "East", "revenue": 150.0},
|
|
||||||
]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
let region = p.proposals.iter().find(|p| p.field == "region").unwrap();
|
|
||||||
assert_eq!(region.kind, FieldKind::Category);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_json_needs_path_selection_when_multiple_arrays() {
|
|
||||||
let raw = json!({
|
|
||||||
"orders": [{"id": 1}, {"id": 2}],
|
|
||||||
"products": [{"name": "A"}, {"name": "B"}],
|
|
||||||
});
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
assert!(p.needs_path_selection());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn single_nested_array_auto_selected() {
|
|
||||||
let raw = json!({
|
|
||||||
"data": [
|
|
||||||
{"region": "East", "revenue": 100.0},
|
|
||||||
{"region": "West", "revenue": 200.0},
|
|
||||||
]
|
|
||||||
});
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
assert!(!p.records.is_empty());
|
|
||||||
assert!(!p.needs_path_selection());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn select_path_populates_records_and_proposals() {
|
|
||||||
let raw = json!({
|
|
||||||
"a": [{"x": 1}, {"x": 2}],
|
|
||||||
"b": [{"y": "foo"}, {"y": "bar"}],
|
|
||||||
});
|
|
||||||
let mut p = ImportPipeline::new(raw);
|
|
||||||
p.select_path("b");
|
|
||||||
assert!(!p.records.is_empty());
|
|
||||||
assert!(!p.proposals.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_model_fails_with_no_accepted_categories() {
|
|
||||||
let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
|
|
||||||
let mut p = ImportPipeline::new(raw);
|
|
||||||
for prop in &mut p.proposals {
|
|
||||||
prop.accepted = false;
|
|
||||||
}
|
|
||||||
assert!(p.build_model().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_model_creates_categories_and_measure_category() {
|
|
||||||
let raw = json!([
|
|
||||||
{"region": "East", "revenue": 100.0},
|
|
||||||
{"region": "West", "revenue": 200.0},
|
|
||||||
]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
let model = p.build_model().unwrap();
|
|
||||||
assert!(model.category("region").is_some());
|
|
||||||
assert!(model.category("Measure").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_model_cells_match_source_data() {
|
|
||||||
let raw = json!([
|
|
||||||
{"region": "East", "revenue": 100.0},
|
|
||||||
{"region": "West", "revenue": 200.0},
|
|
||||||
]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
let model = p.build_model().unwrap();
|
|
||||||
use crate::model::cell::CellKey;
|
|
||||||
let k_east = CellKey::new(vec![
|
|
||||||
("Measure".to_string(), "revenue".to_string()),
|
|
||||||
("region".to_string(), "East".to_string()),
|
|
||||||
]);
|
|
||||||
let k_west = CellKey::new(vec![
|
|
||||||
("Measure".to_string(), "revenue".to_string()),
|
|
||||||
("region".to_string(), "West".to_string()),
|
|
||||||
]);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_cell(&k_east).and_then(|v| v.as_f64()),
|
|
||||||
Some(100.0)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
model.get_cell(&k_west).and_then(|v| v.as_f64()),
|
|
||||||
Some(200.0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn model_name_defaults_to_imported_model() {
|
|
||||||
let raw = json!([{"x": 1.0}]);
|
|
||||||
let p = ImportPipeline::new(raw);
|
|
||||||
assert_eq!(p.model_name, "Imported Model");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
src/lib.rs
Normal file
10
src/lib.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
pub mod command;
|
||||||
|
pub mod draw;
|
||||||
|
pub use improvise_core::format;
|
||||||
|
pub use improvise_formula as formula;
|
||||||
|
pub use improvise_io::import;
|
||||||
|
pub use improvise_core::model;
|
||||||
|
pub use improvise_io::persistence;
|
||||||
|
pub mod ui;
|
||||||
|
pub use improvise_core::view;
|
||||||
|
pub use improvise_core::workbook;
|
||||||
882
src/main.rs
882
src/main.rs
@ -1,588 +1,388 @@
|
|||||||
mod command;
|
use improvise::command;
|
||||||
mod formula;
|
use improvise::draw;
|
||||||
mod import;
|
use improvise::import;
|
||||||
mod model;
|
use improvise::persistence;
|
||||||
mod persistence;
|
use improvise::ui;
|
||||||
mod ui;
|
use improvise::view;
|
||||||
mod view;
|
use improvise::workbook::Workbook;
|
||||||
|
|
||||||
|
use improvise::import::csv_parser::csv_path_p;
|
||||||
|
|
||||||
use std::io::{self, Stdout};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossterm::{
|
use clap::{Parser, Subcommand};
|
||||||
event::{self, Event},
|
use enum_dispatch::enum_dispatch;
|
||||||
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 model::Model;
|
use draw::run_tui;
|
||||||
use ui::app::{App, AppMode};
|
use serde_json::Value;
|
||||||
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;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let cli = Cli::parse();
|
||||||
let arg_config = parse_args(args);
|
let cmd = cli.command.unwrap_or(Commands::Open(OpenTui));
|
||||||
arg_config.run()
|
cmd.run(cli.file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[enum_dispatch]
|
||||||
trait Runnable {
|
trait Runnable {
|
||||||
fn run(self: Box<Self>) -> Result<()>;
|
fn run(self, model_file: Option<PathBuf>) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CmdLineArgs {
|
#[derive(Subcommand)]
|
||||||
file_path: Option<PathBuf>,
|
#[enum_dispatch(Runnable)]
|
||||||
import_path: Option<PathBuf>,
|
enum Commands {
|
||||||
|
/// Import JSON or CSV data, then open TUI (or save with --output)
|
||||||
|
Import(ImportArgs),
|
||||||
|
/// Run a JSON command headless (repeatable)
|
||||||
|
Cmd(CmdArgs),
|
||||||
|
/// Run commands from a script file headless
|
||||||
|
Script(ScriptArgs),
|
||||||
|
/// Open the TUI (default when no subcommand given)
|
||||||
|
Open(OpenTui),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runnable for CmdLineArgs {
|
#[derive(clap::Args)]
|
||||||
fn run(self: Box<Self>) -> Result<()> {
|
struct ImportArgs {
|
||||||
// Load or create model
|
/// Files to import (multiple CSVs merge with a "File" category)
|
||||||
let model = get_initial_model(&self.file_path)?;
|
files: Vec<PathBuf>,
|
||||||
|
|
||||||
// Pre-TUI import: parse JSON and open wizard
|
/// Mark field as category dimension (repeatable)
|
||||||
let import_json = if let Some(ref path) = self.import_path {
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
struct CmdArgs {
|
||||||
|
/// JSON command strings
|
||||||
|
json: Vec<String>,
|
||||||
|
|
||||||
|
/// Model file to load/save
|
||||||
|
#[arg(short, long)]
|
||||||
|
file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
struct ScriptArgs {
|
||||||
|
/// Script file (one JSON command per line, # comments)
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Model file to load/save
|
||||||
|
#[arg(short, long)]
|
||||||
|
file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Args)]
|
||||||
|
struct OpenTui;
|
||||||
|
impl Runnable for OpenTui {
|
||||||
|
fn run(self, model_file: Option<PathBuf>) -> Result<()> {
|
||||||
|
let workbook = get_initial_workbook(&model_file)?;
|
||||||
|
run_tui(workbook, model_file, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runnable for ImportArgs {
|
||||||
|
fn run(self, model_file: Option<PathBuf>) -> Result<()> {
|
||||||
|
if self.files.is_empty() {
|
||||||
|
anyhow::bail!("No files specified for import");
|
||||||
|
}
|
||||||
|
let import_value = get_import_data(&self.files)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))?;
|
||||||
|
|
||||||
|
let config = ImportConfig {
|
||||||
|
categories: self.category,
|
||||||
|
measures: self.measure,
|
||||||
|
time_fields: self.time,
|
||||||
|
skip_fields: self.skip,
|
||||||
|
extractions: parse_colon_pairs(&self.extract),
|
||||||
|
axes: parse_colon_pairs(&self.axis),
|
||||||
|
formulas: self.formula,
|
||||||
|
name: self.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.no_wizard {
|
||||||
|
run_headless_import(import_value, &config, self.output, model_file)
|
||||||
|
} else {
|
||||||
|
run_wizard_import(import_value, &config, model_file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runnable for CmdArgs {
|
||||||
|
fn run(self, _model_file: Option<PathBuf>) -> Result<()> {
|
||||||
|
run_headless_commands(&self.json, &self.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runnable for ScriptArgs {
|
||||||
|
fn run(self, _model_file: Option<PathBuf>) -> Result<()> {
|
||||||
|
run_headless_script(&self.path, &self.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(wb: &mut Workbook, axes: &[(String, String)]) {
|
||||||
|
use view::Axis;
|
||||||
|
let view = wb.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 wb = pipeline.build_model()?;
|
||||||
|
wb.normalize_view_state();
|
||||||
|
apply_axis_overrides(&mut wb, &config.axes);
|
||||||
|
|
||||||
|
if let Some(path) = output.or(model_file) {
|
||||||
|
persistence::save(&wb, &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 workbook = get_initial_workbook(&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(workbook, 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) {
|
match std::fs::read_to_string(path) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Cannot read '{}': {e}", path.display());
|
eprintln!("Cannot read '{}': {e}", path.display());
|
||||||
return Ok(());
|
None
|
||||||
}
|
}
|
||||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
Ok(content) => {
|
||||||
|
if csv_path_p(path) {
|
||||||
|
match crate::import::csv_parser::parse_csv(path) {
|
||||||
|
Ok(records) => Some(Value::Array(records)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("JSON parse error: {e}");
|
eprintln!("CSV parse error: {e}");
|
||||||
return Ok(());
|
None
|
||||||
}
|
}
|
||||||
Ok(json) => Some(json),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
match serde_json::from_str::<Value>(&content) {
|
||||||
};
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
Err(e) => {
|
||||||
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
|
eprintln!("JSON parse error: {e}");
|
||||||
println!("{}", serde_json::to_string(&r)?);
|
None
|
||||||
exit_code = 1;
|
}
|
||||||
continue;
|
Ok(json) => Some(json),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let result = command::dispatch(&mut model, &parsed);
|
|
||||||
if !result.ok {
|
|
||||||
exit_code = 1;
|
|
||||||
}
|
}
|
||||||
println!("{}", serde_json::to_string(&result)?);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(path) = self.file_path {
|
// ── Headless command execution ───────────────────────────────────────────────
|
||||||
persistence::save(&mut model, &path)?;
|
|
||||||
|
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
|
||||||
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
|
||||||
|
let workbook = get_initial_workbook(file)?;
|
||||||
|
let mut app = ui::app::App::new(workbook, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = file {
|
||||||
|
persistence::save(&app.workbook, path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::process::exit(exit_code);
|
std::process::exit(exit_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HelpArgs;
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
impl Runnable for HelpArgs {
|
fn get_initial_workbook(file_path: &Option<PathBuf>) -> Result<Workbook> {
|
||||||
fn run(self: Box<Self>) -> Result<()> {
|
if let Some(path) = file_path {
|
||||||
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 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
|
|
||||||
if let Some(ref path) = file_path {
|
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let mut m = persistence::load(path)
|
let mut wb = persistence::load(path)
|
||||||
.with_context(|| format!("Failed to load {}", path.display()))?;
|
.with_context(|| format!("Failed to load {}", path.display()))?;
|
||||||
m.normalize_view_state();
|
wb.normalize_view_state();
|
||||||
Ok(m)
|
Ok(wb)
|
||||||
} else {
|
} else {
|
||||||
let name = path
|
let name = path
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|s| s.to_str())
|
.and_then(|s| s.to_str())
|
||||||
.unwrap_or("New Model")
|
.unwrap_or("New Model")
|
||||||
.to_string();
|
.to_string();
|
||||||
Ok(Model::new(name))
|
Ok(Workbook::new(name))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(Model::new("New Model"))
|
Ok(Workbook::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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
pub mod category;
|
|
||||||
pub mod cell;
|
|
||||||
pub mod model;
|
|
||||||
|
|
||||||
pub use model::Model;
|
|
||||||
1399
src/model/model.rs
1399
src/model/model.rs
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user