15 Commits

Author SHA1 Message Date
ef79a39721 Add CSV import functionality
- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support

All 201 tests pass.
2026-04-01 01:32:19 -07:00
9fc3f0b5d6 refactor: synthesize previous refactors 2026-04-01 01:01:19 -07:00
3f84ba03cb Revert "refactor: mystery model 3"
This reverts commit 4b721f7543.
2026-04-01 00:46:55 -07:00
4b721f7543 refactor: mystery model 3 2026-04-01 00:46:25 -07:00
6d88de3020 Revert "refactor: mystery model #2"
This reverts commit 87fd6a1620.
2026-04-01 00:41:25 -07:00
87fd6a1620 refactor: mystery model #2 2026-04-01 00:40:22 -07:00
a57d3ed294 Revert "refactor: mystery model #1"
This reverts commit bbebc3344c.
2026-04-01 00:32:12 -07:00
bbebc3344c refactor: mystery model #1 2026-04-01 00:32:07 -07:00
ff08e3c2c2 chore: Revert refactors to give claude a clean slate 2026-04-01 00:26:55 -07:00
8c84256ebc refactor: merge using claude sonnet 2026-04-01 00:25:19 -07:00
d915908354 refactor: unsloth/Qwen3-Coder-Next-GGUF:Q5_K_M refactors the drawing helper 2026-04-01 00:20:19 -07:00
7731c7ceab Revert "refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M"
This reverts commit 98d151f345.
2026-03-31 23:11:21 -07:00
98d151f345 refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M 2026-03-31 23:10:52 -07:00
f1e6e61bca Revert "test: use gpt-oss-20b to do some minor refactoring"
This reverts commit bbd1f48b78.
2026-03-31 22:50:10 -07:00
bbd1f48b78 test: use gpt-oss-20b to do some minor refactoring 2026-03-31 22:50:07 -07:00
92 changed files with 16684 additions and 22676 deletions

73
.beads/.gitignore vendored
View File

@ -1,73 +0,0 @@
# 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.

View File

@ -1,81 +0,0 @@
# 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*

View File

@ -1,54 +0,0 @@
# 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

View File

@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# 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.0 ---

View File

@ -1 +0,0 @@
#!/usr/bin/env sh

View File

@ -1,25 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# 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.0 ---

View File

@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# 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.0 ---

View File

@ -1,25 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# 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.0 ---

View File

@ -1,24 +0,0 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# 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.0 ---

View File

@ -1,7 +0,0 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "embedded",
"dolt_database": "improvise",
"project_id": "1ccea08a-5afb-4b57-acad-78282e9e3af6"
}

View File

@ -1,26 +0,0 @@
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
],
"SessionStart": [
{
"hooks": [
{
"command": "bd prime",
"type": "command"
}
],
"matcher": ""
}
]
}
}

1
.envrc
View File

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

0
.gitattributes vendored
View File

View File

@ -1,44 +0,0 @@
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

View File

@ -1,50 +0,0 @@
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:*)'

View File

@ -1,296 +0,0 @@
# 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

19
.gitignore vendored
View File

@ -3,22 +3,3 @@ target/
.DS_Store
/result
.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/

View File

@ -1,84 +0,0 @@
# 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 -->

View File

@ -4,53 +4,3 @@
- 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.
<!-- 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 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 -->

253
Cargo.lock generated
View File

@ -23,56 +23,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@ -106,15 +56,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@ -166,52 +107,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
@ -232,15 +127,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -275,16 +161,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
@ -340,16 +216,6 @@ dependencies = [
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
@ -377,18 +243,6 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -439,16 +293,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -546,20 +390,15 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "improvise"
version = "0.1.0-rc1"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"crossterm",
"csv",
"dirs",
"enum_dispatch",
"flate2",
"indexmap",
"pest",
"pest_derive",
"pest_meta",
"proptest",
"ratatui",
"serde",
@ -603,12 +442,6 @@ dependencies = [
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@ -734,12 +567,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
@ -775,49 +602,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@ -1082,17 +866,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
@ -1220,18 +993,6 @@ dependencies = [
"syn",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unarray"
version = "0.1.4"
@ -1279,18 +1040,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"

View File

@ -1,14 +1,9 @@
[package]
name = "improvise"
version = "0.1.0-rc1"
version = "0.1.0"
edition = "2021"
description = "Terminal pivot-table modeling in the spirit of Lotus Improv"
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"]
description = "Multi-dimensional data modeling terminal application"
license = "MIT"
[[bin]]
name = "improvise"
@ -27,15 +22,8 @@ flate2 = "1"
unicode-width = "0.2"
dirs = "5"
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]
pest = "2.8.6"
pest_derive = "2.8.6"
pest_meta = "2.8.6"
proptest = "1"
tempfile = "3"
@ -44,12 +32,3 @@ opt-level = 3
lto = true
codegen-units = 1
strip = true
[profile.profiling]
inherits = "release"
strip = false
debug = 2
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"

202
LICENSE
View File

@ -1,202 +0,0 @@
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
View File

@ -1,200 +0,0 @@
# 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.*
![demo](docs/demo.gif)
## 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

File diff suppressed because it is too large Load Diff

View File

@ -1,83 +0,0 @@
#!/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 Normal file
View File

@ -0,0 +1,283 @@
# 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
```

View File

@ -1,293 +0,0 @@
# 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.
---
## 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.

View File

@ -1,284 +0,0 @@
# 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**`![demo](docs/demo.gif)` (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.

View File

@ -1,529 +0,0 @@
# Repository Map (LLM Reference)
Terminal pivot-table modeling app. Rust, Ratatui TUI, command/effect architecture.
Crate `improvise` v0.1.0, Apache-2.0, edition 2021.
---
## How to Find Things
| I need to... | Look in |
|---------------------------------------|----------------------------------------------|
| Add a new keybinding | `command/keymap.rs``default_keymaps()` |
| Add a new user-facing command | `command/cmd/` → implement `Cmd` in the relevant submodule, register in `registry.rs` |
| Add a new state mutation | `ui/effect.rs` → implement `Effect` |
| Change formula evaluation | `model/types.rs``eval_formula()`, `eval_expr()` |
| Change how cells are stored/queried | `model/cell.rs``DataStore` |
| Change category/item behavior | `model/category.rs``Category` |
| Change view axis logic | `view/types.rs``View` |
| Change grid layout computation | `view/layout.rs``GridLayout` |
| Change .improv file format | `persistence/improv.pest` (grammar), `persistence/mod.rs``format_md()`, `parse_md()` |
| Change number display formatting | `format.rs``format_f64()` |
| Change CLI arguments | `main.rs` → clap structs |
| Change import wizard logic | `import/wizard.rs``ImportPipeline` |
| Change grid rendering | `ui/grid.rs``GridWidget` |
| Change TUI frame layout | `draw.rs``draw()` |
| Change app state / mode transitions | `ui/app.rs``App`, `AppMode` |
| Write a test for model logic | `model/types.rs``mod tests` / `mod formula_tests` |
| Write a test for a command | `command/cmd/<module>.rs` → colocated `mod tests` |
---
## Core Types and Traits
### Command/Effect Pipeline (the central architecture pattern)
```
User keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
(immutable) (pure, read-only) (state mutations)
```
```rust
// src/command/cmd/core.rs
pub trait Cmd: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
}
pub struct CmdContext<'a> {
pub model: &'a Model, // immutable
pub layout: &'a GridLayout, // immutable
pub registry: &'a CmdRegistry,
pub mode: &'a AppMode,
pub selected: (usize, usize), // (row, col) cursor
pub row_offset: usize,
pub col_offset: usize,
pub search_query: &'a str,
pub search_mode: bool,
pub yanked: &'a Option<CellValue>,
pub key_code: KeyCode, // the key that triggered this command
pub buffers: &'a HashMap<String, String>,
pub expanded_cats: &'a HashSet<String>,
// panel cursors, tile cursor, visible dimensions...
}
// src/ui/effect.rs
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
fn changes_mode(&self) -> bool { false } // override if effect changes AppMode
}
```
**To add a command**: implement `Cmd` in the appropriate `command/cmd/` submodule, then register in `command/cmd/registry.rs`. Use the `effect_cmd!` macro (in `effect_cmds.rs`) for simple effect-wrapping commands. Bind it in `default_keymaps()`.
**To add an effect**: implement `Effect` in `effect.rs`, add a constructor function.
### Data Model
```rust
// src/model/types.rs
pub struct Model {
pub name: String,
pub categories: IndexMap<String, Category>, // ordered
pub data: DataStore,
pub formulas: Vec<Formula>,
pub views: IndexMap<String, View>,
pub active_view: String,
pub measure_agg: HashMap<String, AggFunc>, // per-measure aggregation override
}
// Key methods:
// add_category(&mut self, name) -> Result<CategoryId> [max 12 regular]
// category(&self, name) -> Option<&Category>
// category_mut(&mut self, name) -> Option<&mut Category>
// set_cell(&mut self, key: CellKey, value: CellValue)
// evaluate(&self, key: &CellKey) -> Option<CellValue> [formulas + raw data]
// evaluate_aggregated(&self, key, none_cats) -> Option<CellValue> [sums over hidden dims]
// recompute_formulas(&mut self, none_cats) [fixed-point formula cache]
// add_formula(&mut self, formula: Formula) [replaces same target+category, adds item]
// remove_formula(&mut self, target, category)
// category_names(&self) -> Vec<&str> [includes virtual]
// regular_category_names(&self) -> Vec<&str> [excludes _Index, _Dim, _Measure]
const MAX_CATEGORIES: usize = 12; // virtual categories don't count
```
```rust
// src/model/cell.rs
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct CellKey(pub Vec<(String, String)>); // always sorted by category name
// CellKey::new(coords) — sorts on construction, enforcing canonical form
// CellKey::with(cat, item) -> Self — returns new key with coord added/replaced
// CellKey::without(cat) -> Self — returns new key with coord removed
// CellKey::get(cat) -> Option<&str>
#[derive(Clone, PartialEq)]
pub enum CellValue {
Number(f64),
Text(String),
Error(String), // formula evaluation error (circular ref, div/0, etc.)
}
// CellValue::as_f64() -> Option<f64>
// CellValue::is_error() -> bool
pub struct DataStore {
cells: HashMap<InternedKey, CellValue>,
pub symbols: SymbolTable,
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>, // secondary index
}
// DataStore::set(&mut self, key: &CellKey, value: CellValue)
// DataStore::get(&self, key: &CellKey) -> Option<&CellValue>
// DataStore::matching_values(&self, partial: &[(String,String)]) -> Vec<CellValue>
// DataStore::matching_cells(&self, partial) -> Vec<(CellKey, CellValue)>
```
```rust
// src/model/category.rs
pub struct Category {
pub id: CategoryId, // usize
pub name: String,
pub kind: CategoryKind,
pub items: IndexMap<String, Item>, // ordered
pub groups: IndexMap<String, Group>,
next_item_id: ItemId, // private, auto-increment
}
// Category::add_item(&mut self, name) -> ItemId [deduplicates by name]
// Category::ordered_item_names(&self) -> Vec<&str> [respects group order]
pub enum CategoryKind { Regular, VirtualIndex, VirtualDim, VirtualMeasure, Label }
```
### Formula System
```rust
// src/formula/ast.rs
pub enum Expr {
Number(f64),
Ref(String), // reference to an item name
BinOp(BinOp, Box<Expr>, Box<Expr>),
UnaryMinus(Box<Expr>),
Agg(AggFunc, Box<Expr>, Option<Filter>),
If(Box<Expr>, Box<Expr>, Box<Expr>),
}
pub enum BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }
pub enum AggFunc { Sum, Avg, Min, Max, Count }
pub struct Formula {
pub raw: String, // "Profit = Revenue - Cost"
pub target: String, // "Profit"
pub target_category: String, // "Measure"
pub expr: Expr,
pub filter: Option<Filter>, // WHERE clause
}
// src/formula/parser.rs
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula>
```
Formula evaluation is in `model/types.rs``eval_formula()` / `eval_expr()`. Operates at full f64 precision. Display rounding in `format.rs` is view-only.
### View and Layout
```rust
// src/view/axis.rs
pub enum Axis { Row, Column, Page, None }
// src/view/types.rs
pub struct View {
pub name: String,
pub category_axes: IndexMap<String, Axis>,
pub page_selections: HashMap<String, String>,
pub hidden_items: HashMap<String, HashSet<String>>,
pub collapsed_groups: HashMap<String, HashSet<String>>,
pub number_format: String, // e.g. ",.0" or ",.2f"
pub prune_empty: bool,
// scroll/selection state...
}
// View::set_axis(&mut self, cat, axis)
// View::axis_of(&self, cat) -> Axis
// View::cycle_axis(&mut self, cat) [Row→Column→Page→None→Row]
// View::transpose(&mut self) [swap Row↔Column]
// View::categories_on(&self, axis) -> Vec<&str>
// View::on_category_added(&mut self, cat) [auto-assigns axis]
// src/view/layout.rs
pub struct GridLayout { /* computed from Model + View */ }
// GridLayout::new(model, view) -> Self
// GridLayout::cell_key(row, col) -> Option<CellKey>
// GridLayout::cell_value(row, col) -> Option<CellValue>
// GridLayout::row_label(row) -> &str
// GridLayout::col_label(col) -> &str
// GridLayout::drill_records(row, col) -> Vec<(CellKey, CellValue)>
// Records mode: auto-detected when _Index on Row + _Dim on Column
```
### App State
```rust
// src/ui/app.rs
pub enum AppMode {
Normal,
Editing { minibuf: MinibufferConfig },
FormulaEdit { minibuf: MinibufferConfig },
FormulaPanel,
CategoryPanel,
ViewPanel,
TileSelect,
CategoryAdd { minibuf: MinibufferConfig },
ItemAdd { minibuf: MinibufferConfig },
ExportPrompt { minibuf: MinibufferConfig },
CommandMode { minibuf: MinibufferConfig },
ImportWizard,
Help,
Quit,
}
// Note: SearchMode is Normal + search_mode:bool flag, not a separate variant.
pub struct App {
pub model: Model,
pub mode: AppMode,
pub file_path: Option<PathBuf>,
pub dirty: bool,
pub help_page: usize,
pub transient_keymap: Option<Arc<Keymap>>, // for prefix keys
// layout cache, drill_state, wizard, buffers, panel cursors, etc.
}
// App::handle_key(&mut self, KeyEvent) -> Result<()> [main input dispatch]
// App::rebuild_layout(&mut self)
// App::is_empty_model(&self) -> bool [true when only virtual categories exist]
```
### Keymap System
```rust
// src/command/keymap.rs
pub enum KeyPattern { Key(KeyCode, KeyModifiers), AnyChar, Any }
pub enum Binding {
Cmd { name: &'static str, args: Vec<String> },
Prefix(Arc<Keymap>), // Emacs-style sub-keymap
Sequence(Vec<(&'static str, Vec<String>)>), // multi-command chain
}
pub enum ModeKey {
Normal, Help, FormulaPanel, CategoryPanel, ViewPanel, TileSelect,
Editing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode,
SearchMode, ImportWizard,
}
// Keymap::lookup(&self, key, mods) -> Option<&Binding>
// Fallback chain: exact(key,mods) → Char with NONE mods → AnyChar → Any
// KeymapSet::default_keymaps() -> Self [builds all 14 mode keymaps]
// KeymapSet::dispatch(&self, ctx, key, mods) -> Option<Vec<Box<dyn Effect>>>
```
---
## File Format (.improv)
Plain-text markdown-like, defined by a PEG grammar (`persistence/improv.pest`).
Parsed by pest; the grammar is the single source of truth for both the parser
and the grammar-walking test generator.
**Not JSON** (JSON is legacy, auto-detected by `{` prefix).
```
v2025-04-09
# Model Name
Initial View: Default
## View: Default
Region: row
Measure: column
|Time Period|: page, Q1 ← pipe-quoted name, page with selection
hidden: Region/Internal
collapsed: |Time Period|/|2024|
format: ,.2f
## Formulas
- Profit = Revenue - Cost [Measure] ← [TargetCategory]
## Category: Region
- North, South, East, West ← bare items, comma-separated
- Coastal_East[Coastal] ← grouped item (one per line)
- Coastal_West[Coastal]
> Coastal ← group definition
## Category: Measure
- Revenue, Cost, Profit
## Data
Region=East, Measure=Revenue = 1200
Region=East, Measure=Cost = 800
Region=West, Measure=Revenue = |pending| ← pipe-quoted text value
```
### Name quoting
Bare names match `[A-Za-z_][A-Za-z0-9_-]*`. Everything else uses CL-style
pipe quoting: `|Income, Gross|`, `|2025|`, `|Name with spaces|`.
Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline).
### Section order
`format_md` writes Views → Formulas → Categories → Data (smallest to largest).
The parser accepts sections in any order.
### Key design choices
- Version line (`v2025-04-09`) enables future format changes.
- `Initial View:` is a top-level header, not embedded in view sections.
- Text cell values are always pipe-quoted to distinguish from numbers.
- Bare items are comma-separated on one line; grouped items get one line each.
Gzip variant: `.improv.gz` (same content, gzipped). Persistence code: `persistence/mod.rs`.
---
## 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`.
---
## Key Dependencies
| Crate | Purpose |
|-------|---------|
| ratatui 0.29 | TUI framework |
| crossterm 0.28 | Terminal backend |
| clap 4.6 (derive) | CLI parsing |
| serde + serde_json | Serialization |
| indexmap 2 | Ordered maps (categories, views) |
| anyhow | Error handling |
| chrono 0.4 | Date parsing in import |
| pest + pest_derive | PEG parser for .improv format |
| flate2 | Gzip for .improv.gz |
| csv | CSV parsing |
| enum_dispatch | CLI subcommand dispatch |
| **dev:** proptest, tempfile, pest_meta | Property testing, temp dirs, grammar AST for test generator |
---
## File Inventory
Lines / tests / path — grouped by layer.
### Model layer
```
1692 / 66t model/types.rs Model struct, formula eval, CRUD, MAX_CATEGORIES=12
621 / 28t model/cell.rs CellKey (canonical sort), CellValue, DataStore (interned)
216 / 6t model/category.rs Category, Item, Group, CategoryKind
79 / 3t model/symbol.rs Symbol interning (SymbolTable)
6 / 0t model/mod.rs
```
### Formula layer
```
461 / 29t formula/parser.rs Recursive descent parser → Formula AST
77 / 0t formula/ast.rs Expr, BinOp, AggFunc, Formula, Filter (data only)
5 / 0t formula/mod.rs
```
### View layer
```
1013 / 23t view/layout.rs GridLayout (pure fn of Model+View), records mode, drill
521 / 28t view/types.rs View config (axes, pages, hidden, collapsed, format)
21 / 0t view/axis.rs Axis enum {Row, Column, Page, None}
7 / 0t view/mod.rs
```
### Command layer
```
command/cmd/ Cmd trait, CmdContext, CmdRegistry, 40+ commands
297 / 2t core.rs Cmd trait, CmdContext, CmdRegistry, parse helpers
586 / 0t registry.rs default_registry() — all command registrations
475 / 10t navigation.rs Move, EnterAdvance, PageNext/Prev
198 / 6t cell.rs ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
330 / 7t commit.rs CommitFormula, CommitCategoryAdd/ItemAdd, CommitExport
437 / 5t effect_cmds.rs effect_cmd! macro, 25+ parseable effect-wrapper commands
409 / 7t grid.rs ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
308 / 8t mode.rs EnterMode, Quit, EditOrDrill, EnterTileSelect, etc.
587 / 13t panel.rs Panel toggle/cycle/cursor, formula/category/view panel cmds
202 / 4t search.rs SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
256 / 7t text_buffer.rs AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
160 / 5t tile.rs MoveTileCursor, TileAxisOp
121 / 0t mod.rs Module declarations, re-exports, test helpers
1066 / 22t command/keymap.rs KeyPattern, Binding, Keymap, ModeKey, 14 mode keymaps
236 / 19t command/parse.rs Script/command-line parser (prefix syntax)
12 / 0t command/mod.rs
```
### UI layer
```
942 / 41t ui/effect.rs Effect trait, 50+ effect types (all state mutations)
914 / 30t ui/app.rs App state, AppMode (15 variants), handle_key, autosave
1036 / 13t ui/grid.rs GridWidget (ratatui), col widths, rendering
617 / 0t ui/help.rs 5-page help overlay, HELP_PAGE_COUNT=5
347 / 0t ui/import_wizard_ui.rs Import wizard overlay rendering
165 / 6t ui/cat_tree.rs Category tree flattener for panel
113 / 0t ui/view_panel.rs View list panel
107 / 0t ui/category_panel.rs Category tree panel
95 / 0t ui/tile_bar.rs Tile bar (axis assignment tiles)
87 / 0t ui/panel.rs Generic panel frame widget
81 / 0t ui/formula_panel.rs Formula list panel
67 / 0t ui/which_key.rs Prefix-key hint popup
12 / 0t ui/mod.rs
```
### Import layer
```
773 / 38t import/wizard.rs ImportPipeline + ImportWizard
292 / 9t import/analyzer.rs Field kind detection (Category/Measure/Time/Skip)
244 / 8t import/csv_parser.rs CSV parsing, multi-file merge
3 / 0t import/mod.rs
```
### Top-level
```
400 / 0t draw.rs TUI event loop (run_tui), frame composition
391 / 0t main.rs CLI entry (clap): open, import, cmd, script
228 / 29t format.rs Number display formatting (view-only rounding)
124 / 0t persistence/improv.pest PEG grammar — single source of truth for .improv format
2291 / 83t persistence/mod.rs .improv save/load (pest parser + format + gzip + legacy JSON)
```
### Context docs
```
context/design-principles.md Architectural principles
context/plan.md Show HN launch plan
context/repo-map.md This file
docs/design-notes.md Product vision & non-goals (salvaged from former SPEC.md)
```
**Total: ~21,400 lines, 568 tests.**
---
## Testing Guidelines
### Coverage target
Aim for **~80% line and branch coverage** on logic code. This is a quality floor, not a
ceiling — go higher where the code warrants it, but don't chase 100% on rendering
widgets or write tests that just exercise trivial getters. Coverage should be run with
`cargo llvm-cov` (available via `nix develop`).
### What to test and how
| Layer | Approach | Notes |
|-------|----------|-------|
| **Model** (types, cell, category, symbol) | Unit tests + **proptest** | The data model is the foundation. Property tests catch invariant violations that hand-picked cases miss (see CellKey sort invariant, axis consistency). |
| **Formula** (parser, eval) | Unit tests per operator/construct | Cover each BinOp, AggFunc, IF, WHERE, unary minus, chained formulas, error cases (div-by-zero, missing ref). Ensure eval uses full f64 precision — never display-rounded values. |
| **View** (types, layout) | Unit tests + **proptest** | Property tests for axis assignment invariants (each category on exactly one axis, transpose is involutive, etc.). Unit tests for layout computation, records mode detection, drill. |
| **Command** (cmd, keymap, parse) | Unit tests | Test command execution by building a `CmdContext` and asserting on returned effects. Test keymap lookup fallback chain. Test script parser with edge cases (quoting, comments, dots). |
| **Persistence** | Round-trip + grammar-generated | `save → load → save` must be identical. Grammar-walking generator produces random valid files from the pest AST; proptests verify `parse(generate())` and `parse(format(parse(generate())))`. Cover groups, formulas, views, hidden items, pipe quoting edge cases. |
| **Format** | Unit tests | Boundary cases: comma placement at 3/4/7 digits, negative numbers, rounding half-away-from-zero (not banker's), zero, small fractions. |
| **Import** (analyzer, csv, wizard) | Unit tests | Field classification heuristics, CSV quoting (RFC 4180), multi-file merge, date extraction. |
| **UI rendering** (grid, panels, draw, help) | Generally skip | Ratatui widgets are hard to unit-test and change frequently. Test the *logic* they consume (layout, cat_tree, format) rather than the rendering itself. |
| **Effects** | Test indirectly | Effects are thin `apply` methods. Test via integration: send a key through `App::handle_key` and assert on resulting app state. The complex ones (drill reconciliation, import) deserve targeted unit tests. |
### Property tests (proptest)
Use property tests for **invariants that must hold across all inputs**, not as a
substitute for example-based tests. Good candidates:
- Structural invariants: CellKey always sorted, each category on exactly one axis,
toggle-collapse is involutive, hide/show roundtrips.
- Serialization roundtrips: save/load identity.
- Determinism: `evaluate` returns the same value for the same inputs.
Keep proptest case counts reasonable. The defaults (256 cases) are fine for most
properties. Don't crank them up to thousands — the test suite should complete in
under 2 seconds. If a property needs more cases to feel confident, that's a sign
the input space should be constrained with better strategies, not brute-forced.
### Bug-fix workflow
Per CLAUDE.md: **write a test that demonstrates the bug before fixing it.** Prefer
a small unit test targeting the specific function over an integration test. The test
should fail on the current code, then pass after the fix. Mark regression tests
with a doc-comment explaining the bug (see `model/types.rs` `formula_tests` for
examples).
### What not to test
- Trivial struct constructors and enum definitions (`ast.rs`, `axis.rs`).
- Ratatui `Widget::render` implementations — these are pure drawing code.
- Module re-export files (`mod.rs`).
- One-line delegation methods.
---
## Patterns to Know
1. **Commands never mutate.** They receive `&CmdContext` (read-only) and return `Vec<Box<dyn Effect>>`.
2. **CellKey is always sorted.** Use `CellKey::new()` — never construct the inner Vec directly.
3. **`category_mut()` for adding items.** `Model` has no `add_item` method; get the category first: `m.category_mut("Region").unwrap().add_item("East")`.
4. **Virtual categories** `_Index`, `_Dim`, and `_Measure` always exist. `is_empty_model()` checks whether any *non-virtual* categories exist. `_Measure` holds numeric data fields and formula targets; `add_formula` auto-adds the target item.
5. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full f64.
5b. **Formula evaluation is fixed-point.** `recompute_formulas(none_cats)` iterates formula evaluation until values stabilize, using a cache. `evaluate_aggregated` checks the cache for formula results. Circular refs produce `CellValue::Error("circular")`.
6. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the current mode, then the corresponding `Keymap` is looked up. Normal + `search_mode=true` maps to `SearchMode`.
7. **`effect_cmd!` macro** generates a command struct that just produces effects. Use for simple commands without complex logic.
8. **`.improv` format is defined by a PEG grammar** (`persistence/improv.pest`). Parsed by pest. Names use CL-style `|...|` pipe quoting when they aren't valid bare identifiers. JSON is legacy only.
9. **`IndexMap`** is used for categories and views to preserve insertion order.
10. **`MAX_CATEGORIES = 12`** applies only to `CategoryKind::Regular`. Virtual/Label categories are exempt.

View File

@ -1,17 +0,0 @@
[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

View File

@ -1,655 +0,0 @@
{"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"]

View File

@ -1,825 +0,0 @@
{"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"]

View File

@ -1,210 +0,0 @@
{"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"]

View File

@ -1,522 +0,0 @@
{"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"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

View File

@ -1,105 +0,0 @@
# 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

View File

@ -1,28 +0,0 @@
# 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.

View File

@ -1,41 +0,0 @@
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
1 Date Region Product Customer Revenue Cost
2 2025-01-15 North Widgets Acme Corp 12000 7200
3 2025-01-22 North Widgets Globex Inc 8500 5100
4 2025-01-08 North Gadgets Acme Corp 6200 4340
5 2025-01-30 North Gadgets Initech 4100 2870
6 2025-01-12 North Sprockets Globex Inc 3400 2380
7 2025-02-05 North Widgets Initech 11000 6600
8 2025-02-18 North Gadgets Acme Corp 7300 5110
9 2025-02-25 North Sprockets Globex Inc 2900 2030
10 2025-03-10 North Widgets Acme Corp 13500 8100
11 2025-03-19 North Gadgets Initech 5800 4060
12 2025-01-09 South Widgets Soylent Ltd 9800 5880
13 2025-01-20 South Widgets Umbrella Co 7200 4320
14 2025-01-14 South Gadgets Soylent Ltd 5500 3850
15 2025-01-28 South Sprockets Umbrella Co 2800 1960
16 2025-02-03 South Widgets Soylent Ltd 10200 6120
17 2025-02-15 South Gadgets Umbrella Co 6100 4270
18 2025-02-22 South Sprockets Soylent Ltd 3100 2170
19 2025-03-07 South Widgets Umbrella Co 8900 5340
20 2025-03-18 South Gadgets Soylent Ltd 6800 4760
21 2025-03-28 South Sprockets Umbrella Co 3500 2450
22 2025-01-06 East Widgets Wonka Industries 14200 8520
23 2025-01-17 East Widgets Stark Enterprises 11800 7080
24 2025-01-23 East Gadgets Wonka Industries 8900 6230
25 2025-01-31 East Gadgets Stark Enterprises 7400 5180
26 2025-02-10 East Widgets Wonka Industries 15000 9000
27 2025-02-20 East Sprockets Stark Enterprises 4200 2940
28 2025-02-28 East Gadgets Wonka Industries 9200 6440
29 2025-03-05 East Widgets Stark Enterprises 12500 7500
30 2025-03-14 East Sprockets Wonka Industries 4800 3360
31 2025-03-25 East Gadgets Stark Enterprises 8100 5670
32 2025-01-11 West Widgets Oceanic Airlines 10500 6300
33 2025-01-19 West Gadgets Cyberdyne Systems 6700 4690
34 2025-01-27 West Sprockets Oceanic Airlines 3200 2240
35 2025-02-06 West Widgets Cyberdyne Systems 11200 6720
36 2025-02-14 West Gadgets Oceanic Airlines 7100 4970
37 2025-02-24 West Sprockets Cyberdyne Systems 3600 2520
38 2025-03-03 West Widgets Oceanic Airlines 12800 7680
39 2025-03-12 West Gadgets Cyberdyne Systems 7500 5250
40 2025-03-21 West Sprockets Oceanic Airlines 4000 2800
41 2025-03-30 West Widgets Cyberdyne Systems 9800 5880

View File

@ -1,117 +0,0 @@
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

260
flake.lock generated
View File

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

View File

@ -7,7 +7,6 @@
url = "github:oxalica/rust-overlay";
};
flake-utils.url = "github:numtide/flake-utils";
crate2nix.url = "github:nix-community/crate2nix";
};
outputs = {
@ -15,46 +14,65 @@
nixpkgs,
rust-overlay,
flake-utils,
crate2nix,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
pkgs = import nixpkgs {inherit system overlays;};
isLinux = pkgs.lib.hasInfix "linux" system;
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "clippy" "rustfmt" "llvm-tools-preview"];
};
generatedCargoNix = crate2nix.tools.${system}.generatedCargoNix {
name = "improvise";
src = ./.;
};
cargoNix = import generatedCargoNix {
pkgs = pkgs;
extensions = ["rust-src" "clippy" "rustfmt"];
targets = pkgs.lib.optionals isLinux ["x86_64-unknown-linux-musl"];
};
in {
devShells.default = pkgs.mkShell {
nativeBuildInputs = [
devShells.default = pkgs.mkShell ({
nativeBuildInputs =
[
rustToolchain
pkgs.pkg-config
pkgs.rust-analyzer
crate2nix.packages.${system}.default
pkgs.cargo-expand
pkgs.cargo-llvm-cov
# Demo recording and release tooling
pkgs.asciinema
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 "$@"'')
]
++ pkgs.lib.optionals isLinux [
# Provide cc (gcc) for building proc-macro / build-script crates
# that target the host (x86_64-unknown-linux-gnu).
pkgs.gcc
# musl-gcc wrapper for the static musl target.
pkgs.pkgsMusl.stdenv.cc
];
RUST_BACKTRACE = "1";
};
packages.default = cargoNix.rootCrate.build;
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;
};
});
}

12789
llama-server.log Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +0,0 @@
#!/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

View File

@ -1,198 +0,0 @@
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.set_cell(key, CellValue::Number(42.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
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.set_cell(key, CellValue::Number(99.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
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.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, &reg);
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, &reg);
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, &reg);
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, &reg);
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)]
}
}

View File

@ -1,321 +0,0 @@
use crate::model::cell::{CellKey, CellValue};
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{Cmd, CmdContext};
use super::navigation::{viewport_effects, CursorState, EnterAdvance};
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::Model;
#[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, &reg);
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 = Model::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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
let effects = CommitItemAdd.execute(&ctx);
assert!(effects.is_empty());
}
#[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, &reg);
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
/// or apply directly; for real keys, write to the model.
fn commit_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {
if let Some((record_idx, col_name)) = crate::view::synthetic_record_info(key) {
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx,
col_name,
new_value: value.to_string(),
}));
} else if value.is_empty() {
effects.push(Box::new(effect::ClearCell(key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(key.clone(), CellValue::Number(n))));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(
key.clone(),
CellValue::Text(value.to_string()),
)));
effects.push(effect::mark_dirty());
}
}
/// 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,
}
/// Commit a cell edit, advance the cursor, and re-enter edit mode.
/// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
#[derive(Debug)]
pub struct CommitAndAdvance {
pub key: CellKey,
pub value: String,
pub advance: AdvanceDir,
pub cursor: CursorState,
}
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(&self.key, &self.value, &mut effects);
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 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));
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();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Default formula target to _Measure (the virtual measure category).
// _Measure dynamically includes all formula targets.
effects.push(Box::new(effect::AddFormula {
raw: buf,
target_category: "_Measure".to_string(),
}));
effects.push(effect::mark_dirty());
effects.push(effect::set_status("Formula added"));
effects.push(effect::change_mode(AppMode::FormulaPanel));
effects
}
}
/// 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),
]
}
}

View File

@ -1,297 +0,0 @@
use std::collections::HashMap;
use std::fmt::Debug;
use crossterm::event::KeyCode;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::effect::{Effect, Panel};
use crate::view::{Axis, GridLayout};
/// Read-only context available to commands for decision-making.
pub struct CmdContext<'a> {
pub model: &'a Model,
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 [String],
pub view_forward_stack: &'a [String],
/// 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> {
pub fn cell_key(&self) -> Option<CellKey> {
self.layout.cell_key(self.selected.0, self.selected.1)
}
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());
}
}

View File

@ -1,461 +0,0 @@
use crate::model::cell::CellValue;
use crate::ui::effect::{self, Effect};
use crate::view::Axis;
use super::core::{require_args, Cmd, CmdContext};
#[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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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(),
})]
}
);

View File

@ -1,409 +0,0 @@
use crate::model::cell::CellValue;
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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!["View 2".to_string()];
let mut ctx = make_ctx(&m, &layout, &reg);
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!["Default".to_string()];
let mut ctx = make_ctx(&m, &layout, &reg);
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, &reg);
let effects = TogglePruneEmpty.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("TogglePruneEmpty"),
"Expected TogglePruneEmpty, 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();
// Capture the records snapshot NOW (before we switch views).
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
if self.key.0.is_empty() {
ctx.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
ctx.model
.data
.matching_cells(&self.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.model.active_view().prune_empty;
vec![
Box::new(effect::TogglePruneEmpty),
effect::set_status(if currently_on {
"Showing all rows/columns"
} else {
"Hiding empty rows/columns"
}),
]
}
}
/// Toggle between records mode 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 {
// Navigate back to the previous view (restores original axes)
return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")];
}
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
let records_name = "_Records".to_string();
// 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::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>> {
let is_records = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.is_some();
if !is_records {
return vec![effect::set_status(
"add-record-row only works in records mode",
)];
}
// Build a CellKey from the current page filters
let view = ctx.model.active_view();
let page_cats: Vec<String> = view
.categories_on(crate::view::Axis::Page)
.into_iter()
.map(String::from)
.collect();
let coords: Vec<(String, String)> = page_cats
.iter()
.map(|cat| {
let sel = view.page_selection(cat).unwrap_or("").to_string();
(cat.clone(), sel)
})
.filter(|(_, v)| !v.is_empty())
.collect();
let key = crate::model::cell::CellKey::new(coords);
vec![
Box::new(effect::SetCell(key, CellValue::Number(0.0))),
effect::mark_dirty(),
effect::set_status("Added new record row"),
]
}
}

View File

@ -1,121 +0,0 @@
pub mod core;
pub mod navigation;
pub mod mode;
pub mod cell;
pub mod search;
pub mod panel;
pub mod grid;
pub mod tile;
pub mod text_buffer;
pub mod commit;
pub mod effect_cmds;
pub mod registry;
// 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::model::Model;
use crate::ui::app::AppMode;
use crate::ui::effect::Effect;
use crate::view::GridLayout;
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(model: &Model) -> GridLayout {
GridLayout::new(model, model.active_view())
}
pub fn make_ctx<'a>(
model: &'a Model,
layout: &'a GridLayout,
registry: &'a CmdRegistry,
) -> CmdContext<'a> {
let view = model.active_view();
let (sr, sc) = view.selected;
CmdContext {
model,
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: &[],
display_value: {
let key = layout.cell_key(sr, sc);
key.as_ref()
.and_then(|k| 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() -> Model {
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Type").unwrap().add_item("Clothing");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Month").unwrap().add_item("Feb");
m
}
pub fn three_cat_model_with_page() -> Model {
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.add_category("Region").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Type").unwrap().add_item("Clothing");
m.category_mut("Month").unwrap().add_item("Jan");
m.category_mut("Month").unwrap().add_item("Feb");
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("Region").unwrap().add_item("South");
m.category_mut("Region").unwrap().add_item("East");
let view = m.active_view_mut();
view.set_axis("Region", crate::view::Axis::Page);
m
}
pub fn effects_debug(effects: &[Box<dyn Effect>]) -> String {
format!("{:?}", effects)
}
pub fn make_registry() -> CmdRegistry {
default_registry()
}
}

View File

@ -1,308 +0,0 @@
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::model::Model;
#[test]
fn enter_edit_mode_produces_editing_mode() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = EnterEditMode {
initial_value: String::new(),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
let dbg = format!("{:?}", effects[1]);
assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
}
#[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, &reg);
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 = Model::new("Empty");
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
let effects = EditOrDrill.execute(&ctx);
let dbg = effects_debug(&effects);
assert!(dbg.contains("Editing"), "Expected Editing mode, 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, &reg);
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 ───────────────────────────────────────────────────────
/// Enter editing mode with an initial buffer value.
#[derive(Debug)]
pub struct EnterEditMode {
pub initial_value: String,
}
impl Cmd for EnterEditMode {
fn name(&self) -> &'static str {
"enter-edit-mode"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![
Box::new(effect::SetBuffer {
name: "edit".to_string(),
value: self.initial_value.clone(),
}),
effect::change_mode(AppMode::editing()),
]
}
}
/// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
/// (categories on `Axis::None`, no records mode), drill into it instead of
/// editing. Otherwise enter edit mode with the current displayed value.
#[derive(Debug)]
pub struct EditOrDrill;
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)
});
// In records mode (synthetic key), always edit directly — no drilling.
let is_synthetic = ctx
.cell_key()
.as_ref()
.and_then(crate::view::synthetic_record_info)
.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);
}
EnterEditMode {
initial_value: ctx.display_value.clone(),
}
.execute(ctx)
}
}
/// Thin command wrapper around the `EnterEditAtCursor` effect so it can
/// participate in `Binding::Sequence`.
#[derive(Debug)]
pub struct EnterEditAtCursorCmd;
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)]
}
}
/// 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![]
}
}
}

View File

@ -1,475 +0,0 @@
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 {
(r, c) // already at bottom-right; stay
};
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()
}
}
#[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, &reg);
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, &reg);
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, &reg);
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}"
);
}
#[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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
let effects = PagePrev.execute(&ctx);
assert!(!effects.is_empty());
let dbg = effects_debug(&effects);
assert!(
dbg.contains("SetPageSelection"),
"Expected SetPageSelection, got: {dbg}"
);
}
}
/// 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.model.active_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()
}

View File

@ -1,587 +0,0 @@
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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.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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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.model.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.model.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.model.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![]
}
}
}

View File

@ -1,587 +0,0 @@
use crate::model::cell::CellKey;
use crate::ui::app::AppMode;
use crate::ui::effect::Panel;
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(
&EnterEditMode {
initial_value: String::new(),
},
|args| {
let val = args.first().cloned().unwrap_or_default();
Ok(Box::new(EnterEditMode { initial_value: val }))
},
|_args, ctx| {
Ok(Box::new(EnterEditMode {
initial_value: ctx.display_value.clone(),
}))
},
);
r.register_nullary(|| Box::new(EditOrDrill));
r.register_nullary(|| Box::new(EnterEditAtCursorCmd));
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)?;
let mode = match args[0].as_str() {
"normal" => AppMode::Normal,
"help" => AppMode::Help,
"formula-panel" => AppMode::FormulaPanel,
"category-panel" => AppMode::CategoryPanel,
"view-panel" => AppMode::ViewPanel,
"tile-select" => AppMode::TileSelect,
"command" => AppMode::command_mode(),
"category-add" => AppMode::category_add(),
"editing" => AppMode::editing(),
"formula-edit" => AppMode::formula_edit(),
"export-prompt" => AppMode::export_prompt(),
other => return Err(format!("Unknown mode: {other}")),
};
Ok(Box::new(EnterMode(mode)))
});
// ── 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.model.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 ───────────────────────────────────────────────────────────
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
},
|args| {
if args.len() < 2 {
return Err("commit-cell-edit requires a value and coords".into());
}
Ok(Box::new(CommitAndAdvance {
key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(),
advance: AdvanceDir::Down,
cursor: CursorState::default(),
}))
},
|_args, ctx| {
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),
}))
},
);
r.register(
&CommitAndAdvance {
key: CellKey::new(vec![]),
value: String::new(),
advance: AdvanceDir::Right,
cursor: CursorState::default(),
},
|_| Err("commit-and-advance-right requires context".into()),
|_args, ctx| {
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),
}))
},
);
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
}

View File

@ -1,202 +0,0 @@
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, &reg);
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, &reg);
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, &reg);
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.set_cell(
CellKey::new(vec![
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]),
CellValue::Number(42.0),
);
m.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, &reg);
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))]
}
}

View File

@ -1,256 +0,0 @@
use crossterm::event::KeyCode;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect};
use super::core::{read_buffer, Cmd, CmdContext};
#[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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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),
]
}
}
}
}

View File

@ -1,160 +0,0 @@
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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, &reg);
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.model.active_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![]
}
}
}

249
src/command/dispatch.rs Normal file
View File

@ -0,0 +1,249 @@
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_headless(model, path, model_name.as_deref(), array_path.as_deref()),
}
}
fn import_headless(
model: &mut Model,
path: &str,
model_name: Option<&str>,
array_path: Option<&str>,
) -> CommandResult {
let is_csv = path.ends_with(".csv");
let records = if is_csv {
// Parse CSV file
match crate::import::csv_parser::parse_csv(path) {
Ok(recs) => recs,
Err(e) => return CommandResult::err(e.to_string()),
}
} else {
// Parse JSON file
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}")),
};
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 {
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);
// Build via ImportPipeline
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
// For JSON, we need the original parsed value
// Re-read and parse to get it (or pass it up from above)
serde_json::from_str(&std::fs::read_to_string(path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = crate::import::wizard::ImportPipeline {
raw,
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("Imported successfully")
}
Err(e) => CommandResult::err(e.to_string()),
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

124
src/command/types.rs Normal file
View File

@ -0,0 +1,124 @@
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()),
}
}
}

View File

@ -1,400 +0,0 @@
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use crate::model::Model;
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::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(
model: Model,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
} 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",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget::new(app.help_page), size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if 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.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.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.model, &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.model);
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
}
} else {
grid_area = area;
}
f.render_widget(
GridWidget::new(
&app.model,
&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.model, &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.model.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -1,229 +0,0 @@
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), "");
}
}

View File

@ -16,7 +16,7 @@ pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(parse_where).transpose()?;
let filter = filter.map(|w| parse_where(w)).transpose()?;
let expr = parse_expr(expr_str.trim())?;
@ -38,12 +38,6 @@ fn split_where(s: &str) -> (&str, Option<&str>) {
i += 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];
@ -60,23 +54,14 @@ fn split_where(s: &str) -> (&str, Option<&str>) {
(s, None)
}
/// Strip pipe or double-quote delimiters from a value.
fn unquote(s: &str) -> String {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('|') && s.ends_with('|')) {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
fn parse_where(s: &str) -> Result<Filter> {
// Format: Category = "Item" or Category = |Item| or Category = Item
// Format: Category = "Item" or Category = Item
let eq_pos = s
.find('=')
.ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
let category = unquote(&s[..eq_pos]);
let item = unquote(&s[eq_pos + 1..]);
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 })
}
@ -191,18 +176,6 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
}
tokens.push(Token::Str(s));
}
'|' => {
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::Ident(s));
}
c if c.is_ascii_digit() || c == '.' => {
let mut num = String::new();
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
@ -218,7 +191,7 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
{
// Don't consume trailing spaces if next non-space is operator
if chars[i] == ' ' {
// Peek ahead past spaces to find the next word/token
// Peek ahead
let j = i + 1;
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
if matches!(
@ -230,37 +203,10 @@ fn tokenize(s: &str) -> Result<Vec<Token>> {
| Some('^')
| Some(')')
| Some(',')
| Some('<')
| Some('>')
| Some('=')
| Some('!')
| Some('"')
| None
) {
break;
}
// Break if the identifier collected so far is a keyword
let trimmed = ident.trim_end().to_ascii_uppercase();
if matches!(
trimmed.as_str(),
"WHERE" | "SUM" | "AVG" | "MIN" | "MAX" | "COUNT" | "IF"
) {
break;
}
// Also break if the next word is a keyword
let rest: String = chars[j..].iter().collect();
let next_word: String = rest
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let upper = next_word.to_ascii_uppercase();
if matches!(
upper.as_str(),
"WHERE" | "SUM" | "AVG" | "MIN" | "MAX" | "COUNT" | "IF"
) {
break;
}
}
ident.push(chars[i]);
i += 1;
@ -353,7 +299,7 @@ fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.eq_ignore_ascii_case("WHERE") {
if kw.to_ascii_uppercase() == "WHERE" {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => {
@ -464,15 +410,15 @@ mod tests {
#[test]
fn parse_simple_subtraction() {
let f = parse_formula("Profit = Revenue - Cost", "Foo").unwrap();
let f = parse_formula("Profit = Revenue - Cost", "Measure").unwrap();
assert_eq!(f.target, "Profit");
assert_eq!(f.target_category, "Foo");
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\"", "Foo").unwrap();
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");
@ -481,25 +427,25 @@ mod tests {
#[test]
fn parse_sum_aggregation() {
let f = parse_formula("Total = SUM(Revenue)", "Foo").unwrap();
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)", "Foo").unwrap();
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)", "Foo").unwrap();
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", "Foo").unwrap();
let f = parse_formula("Fixed = 42", "Measure").unwrap();
assert!(matches!(f.expr, Expr::Number(n) if (n - 42.0).abs() < 1e-10));
}
@ -512,276 +458,4 @@ mod tests {
fn parse_missing_equals_returns_error() {
assert!(parse_formula("BadFormula Revenue Cost", "Cat").is_err());
}
// ── Aggregate functions ─────────────────────────────────────────────
#[test]
fn parse_min_aggregation() {
let f = parse_formula("Lo = MIN(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Min, _, _)));
}
#[test]
fn parse_max_aggregation() {
let f = parse_formula("Hi = MAX(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Max, _, _)));
}
#[test]
fn parse_count_aggregation() {
let f = parse_formula("N = COUNT(Revenue)", "Foo").unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Count, _, _)));
}
// ── Aggregate with WHERE filter ─────────────────────────────────────
#[test]
fn parse_sum_with_top_level_where_works() {
let f = parse_formula(
"EastTotal = SUM(Revenue) WHERE Region = \"East\"",
"Foo",
)
.unwrap();
assert!(matches!(f.expr, Expr::Agg(AggFunc::Sum, _, _)));
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
}
/// Regression: WHERE inside aggregate parens must tokenize correctly.
/// The tokenizer must not merge "Revenue WHERE" into a single identifier.
#[test]
fn parse_sum_with_inline_where_filter() {
let f = parse_formula(
"EastTotal = SUM(Revenue WHERE Region = \"East\")",
"Foo",
)
.unwrap();
if let Expr::Agg(AggFunc::Sum, inner, Some(filter)) = &f.expr {
assert!(matches!(**inner, Expr::Ref(_)));
assert_eq!(filter.category, "Region");
assert_eq!(filter.item, "East");
} else {
panic!("Expected SUM with inline WHERE filter, got: {:?}", f.expr);
}
}
// ── Comparison operators ────────────────────────────────────────────
#[test]
fn parse_if_with_comparison_operators() {
// Test each comparison operator in an IF expression
let f = parse_formula("X = IF(A != 0, A, 1)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A < 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A <= 10, A, 10)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A >= 10, 10, A)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
let f = parse_formula("X = IF(A = B, 1, 0)", "Cat").unwrap();
assert!(matches!(f.expr, Expr::If(_, _, _)));
}
// ── Quoted strings in WHERE ─────────────────────────────────────────
#[test]
fn parse_where_with_quoted_string_inside_expression() {
// WHERE inside a formula string with quotes
let f = parse_formula("X = Revenue WHERE Region = \"West Coast\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "West Coast");
}
// ── Power operator ──────────────────────────────────────────────────
#[test]
fn parse_power_operator() {
let f = parse_formula("Sq = X ^ 2", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Pow, _, _)));
}
// ── Unary minus ─────────────────────────────────────────────────────
#[test]
fn parse_unary_minus() {
let f = parse_formula("Neg = -Revenue", "Foo").unwrap();
assert!(matches!(f.expr, Expr::UnaryMinus(_)));
}
// ── Division and multiplication ─────────────────────────────────────
#[test]
fn parse_multiplication() {
let f = parse_formula("Double = Revenue * 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Mul, _, _)));
}
#[test]
fn parse_division() {
let f = parse_formula("Half = Revenue / 2", "Foo").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Div, _, _)));
}
// ── Parenthesized expression ────────────────────────────────────────
#[test]
fn parse_nested_parens() {
let f = parse_formula("X = ((A + B))", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
}
// ── Aggregate function name used as ref (no parens) ─────────────────
#[test]
fn parse_aggregate_name_without_parens_is_ref() {
// "SUM" without parens should be treated as a reference, not a function
let f = parse_formula("X = SUM + 1", "Cat").unwrap();
assert!(matches!(f.expr, Expr::BinOp(BinOp::Add, _, _)));
if let Expr::BinOp(_, lhs, _) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(_)));
}
}
#[test]
fn parse_if_without_parens_is_ref() {
// "IF" without parens should be treated as a reference
let f = parse_formula("X = IF + 1", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, _) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(_)));
} else {
panic!("Expected BinOp(Add), got: {:?}", f.expr);
}
}
// ── Quoted string in tokenizer ──────────────────────────────────────
#[test]
fn parse_quoted_string_in_where() {
// Quoted strings work in top-level WHERE clauses
let f = parse_formula("X = Revenue WHERE Region = \"East\"", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East");
}
// ── Error paths ─────────────────────────────────────────────────────
#[test]
fn parse_unexpected_token_error() {
use super::parse_expr;
// Extra tokens after a valid expression
assert!(parse_expr("1 + 2 3").is_err());
}
#[test]
fn parse_unexpected_character_error() {
use super::parse_expr;
assert!(parse_expr("@invalid").is_err());
}
#[test]
fn parse_empty_expression_error() {
use super::parse_expr;
assert!(parse_expr("").is_err());
}
#[test]
fn tokenizer_breaks_at_where_keyword() {
use super::tokenize;
let tokens = tokenize("Revenue WHERE Region").unwrap();
// Should produce 3 tokens: Ident("Revenue"), Ident("WHERE"), Ident("Region")
assert_eq!(tokens.len(), 3, "Expected 3 tokens, got: {tokens:?}");
}
// ── Multi-word identifiers ──────────────────────────────────────────
#[test]
fn parse_multi_word_identifier() {
let f = parse_formula("Total Revenue = Base Revenue + Bonus", "Foo").unwrap();
assert_eq!(f.target, "Total Revenue");
}
// ── WHERE inside quotes in split_where ──────────────────────────────
#[test]
fn split_where_ignores_where_inside_quotes() {
// WHERE inside quotes should not be treated as a keyword
let f = parse_formula("X = Revenue WHERE Region = \"WHERE\"", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "WHERE");
}
// ── Pipe-quoted identifiers ─────────────────────────────────────────
#[test]
fn pipe_quoted_identifier_in_expression() {
let f = parse_formula("|Total Revenue| = |Base Revenue| + Bonus", "Foo").unwrap();
assert_eq!(f.target, "|Total Revenue|");
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "Base Revenue"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "Bonus"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_keyword_as_identifier() {
// A category named "WHERE" can be referenced with pipes
let f = parse_formula("X = |WHERE| + |SUM|", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "WHERE"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "SUM"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_identifier_with_special_chars() {
// Pipes allow characters that would normally break tokenization
let f = parse_formula("X = |Revenue (USD)| + |Cost + Tax|", "Cat").unwrap();
if let Expr::BinOp(BinOp::Add, lhs, rhs) = &f.expr {
assert!(matches!(**lhs, Expr::Ref(ref s) if s == "Revenue (USD)"));
assert!(matches!(**rhs, Expr::Ref(ref s) if s == "Cost + Tax"));
} else {
panic!("Expected Add, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_in_aggregate() {
let f = parse_formula("X = SUM(|Net Revenue|)", "Cat").unwrap();
if let Expr::Agg(AggFunc::Sum, inner, None) = &f.expr {
assert!(matches!(**inner, Expr::Ref(ref s) if s == "Net Revenue"));
} else {
panic!("Expected SUM aggregate, got: {:?}", f.expr);
}
}
#[test]
fn pipe_quoted_in_where_filter_value() {
let f = parse_formula("X = Revenue WHERE Region = |East Coast|", "Foo").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(filter.item, "East Coast");
}
#[test]
fn pipe_quoted_in_inline_where() {
let f = parse_formula(
"X = SUM(Revenue WHERE |Region Name| = |East Coast|)",
"Foo",
)
.unwrap();
if let Expr::Agg(AggFunc::Sum, _, Some(filter)) = &f.expr {
assert_eq!(filter.category, "Region Name");
assert_eq!(filter.item, "East Coast");
} else {
panic!("Expected SUM with WHERE filter, got: {:?}", f.expr);
}
}
}

View File

@ -1,4 +1,3 @@
use chrono::{Datelike, NaiveDate};
use serde_json::Value;
use std::collections::HashSet;
@ -14,24 +13,12 @@ pub enum FieldKind {
Label,
}
/// Date components that can be extracted from a date field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DateComponent {
Year,
Month,
Quarter,
}
#[derive(Debug, Clone)]
pub struct FieldProposal {
pub field: String,
pub kind: FieldKind,
pub distinct_values: Vec<String>,
pub accepted: bool,
/// Detected chrono format string (e.g., "%m/%d/%Y"). Only set for TimeCategory.
pub date_format: Option<String>,
/// Which date components to extract as new categories.
pub date_components: Vec<DateComponent>,
}
impl FieldProposal {
@ -40,60 +27,11 @@ impl FieldProposal {
FieldKind::Category => "Category (dimension)",
FieldKind::Measure => "Measure (numeric)",
FieldKind::TimeCategory => "Time Category",
FieldKind::Label => "Label (per-row, drill-view only)",
FieldKind::Label => "Label/Identifier (skip)",
}
}
}
/// 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> {
@ -127,8 +65,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Measure,
distinct_values: vec![],
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -136,19 +72,26 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
let distinct: HashSet<&str> = values.iter().filter_map(|v| v.as_str()).collect();
let distinct_vec: Vec<String> = distinct.into_iter().map(String::from).collect();
let n = distinct_vec.len();
let _total = values.len();
// 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);
// 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 date_format.is_some() {
if looks_like_date {
return FieldProposal {
field,
kind: FieldKind::TimeCategory,
distinct_values: distinct_vec,
accepted: true,
date_format,
date_components: vec![],
};
}
@ -158,8 +101,6 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
kind: FieldKind::Category,
distinct_values: distinct_vec,
accepted: true,
date_format: None,
date_components: vec![],
};
}
@ -167,9 +108,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
field,
kind: FieldKind::Label,
distinct_values: distinct_vec,
accepted: true,
date_format: None,
date_components: vec![],
accepted: false,
};
}
@ -178,9 +117,7 @@ pub fn analyze_records(records: &[Value]) -> Vec<FieldProposal> {
field,
kind: FieldKind::Label,
distinct_values: vec![],
accepted: true,
date_format: None,
date_components: vec![],
accepted: false,
}
})
.collect()
@ -223,70 +160,3 @@ fn find_array_paths_inner(value: &Value, prefix: &str, paths: &mut Vec<String>)
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_iso_date_format() {
let samples = vec!["2025-01-15", "2025-02-28", "2024-12-01"];
assert_eq!(detect_date_format(&samples), Some("%Y-%m-%d".to_string()));
}
#[test]
fn detect_us_date_format() {
let samples = vec!["03/31/2026", "01/15/2025", "12/25/2024"];
assert_eq!(detect_date_format(&samples), Some("%m/%d/%Y".to_string()));
}
#[test]
fn detect_short_year_format() {
// Two-digit years are ambiguous with four-digit format, so %m/%d/%Y
// matches first. This is expected — the user can override in the wizard.
let samples = vec!["03/31/26", "01/15/25"];
assert!(detect_date_format(&samples).is_some());
}
#[test]
fn detect_no_date_format() {
let samples = vec!["hello", "world"];
assert_eq!(detect_date_format(&samples), None);
}
#[test]
fn extract_year_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Year);
assert_eq!(result, Some("2026".to_string()));
}
#[test]
fn extract_month_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Month);
assert_eq!(result, Some("2026-03".to_string()));
}
#[test]
fn extract_quarter_component() {
let result = extract_date_component("03/31/2026", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2026-Q1".to_string()));
}
#[test]
fn extract_quarter_q4() {
let result = extract_date_component("12/15/2025", "%m/%d/%Y", DateComponent::Quarter);
assert_eq!(result, Some("2025-Q4".to_string()));
}
#[test]
fn analyze_detects_time_category_with_format() {
let records: Vec<Value> = vec![
serde_json::json!({"Date": "01/15/2025", "Amount": 100}),
serde_json::json!({"Date": "02/20/2025", "Amount": 200}),
];
let proposals = analyze_records(&records);
let date_prop = proposals.iter().find(|p| p.field == "Date").unwrap();
assert_eq!(date_prop.kind, FieldKind::TimeCategory);
assert_eq!(date_prop.date_format, Some("%m/%d/%Y".to_string()));
}
}

View File

@ -1,22 +1,15 @@
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>> {
pub fn parse_csv(path: &str) -> 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()))?;
.with_context(|| format!("Failed to open CSV file: {path}"))?;
// Detect if first row looks like headers (strings) or data (mixed)
let has_headers = reader.headers().is_ok();
@ -56,28 +49,6 @@ pub fn parse_csv(path: &Path) -> Result<Vec<Value>> {
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;
@ -101,29 +72,25 @@ fn parse_csv_field(field: &str) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, path::PathBuf};
use std::fs;
use tempfile::tempdir;
fn create_temp_csv(content: &str) -> (PathBuf, tempfile::TempDir) {
fn create_temp_csv(content: &str) -> (String, tempfile::TempDir) {
let dir = tempdir().unwrap();
let path = dir.path().join("test.csv");
fs::write(&path, content).unwrap();
(path, dir)
(path.to_string_lossy().to_string(), dir)
}
#[test]
fn parse_simple_csv() {
let (path, _dir) =
create_temp_csv("Region,Product,Revenue\nEast,Shirts,1000\nWest,Shirts,800");
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))
);
assert_eq!(records[0]["Revenue"], Value::Number(serde_json::Number::from(1000)));
}
#[test]
@ -134,24 +101,17 @@ mod tests {
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())
);
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 (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())
);
assert_eq!(records[0]["Description"], Value::String("A nice shirt".to_string()));
}
#[test]
@ -166,110 +126,18 @@ mod tests {
#[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 (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_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
@ -282,19 +150,10 @@ mod tests {
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]["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())
);
assert_eq!(records[0]["Description"], Value::String("VENMO PAYMENT 260331".to_string()));
assert_eq!(records[1]["Amount"], Value::Number(serde_json::Number::from_f64(-240.00).unwrap()));
}
}

View File

@ -2,10 +2,8 @@ use anyhow::{anyhow, Result};
use serde_json::Value;
use super::analyzer::{
analyze_records, extract_array_at_path, extract_date_component, find_array_paths,
DateComponent, FieldKind, FieldProposal,
analyze_records, extract_array_at_path, find_array_paths, FieldKind, FieldProposal,
};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
@ -21,8 +19,6 @@ pub struct ImportPipeline {
pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>,
pub model_name: String,
/// Raw formula strings to add to the model (e.g., "Profit = Revenue - Cost").
pub formulas: Vec<String>,
}
impl ImportPipeline {
@ -35,7 +31,6 @@ impl ImportPipeline {
records: vec![],
proposals: vec![],
model_name: "Imported Model".to_string(),
formulas: vec![],
};
// Auto-select if root is an array or there is exactly one candidate path.
@ -94,40 +89,11 @@ impl ImportPipeline {
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
.collect();
let labels: Vec<&FieldProposal> = self
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Label)
.collect();
if categories.is_empty() {
return Err(anyhow!("At least one category must be accepted"));
}
// Collect date component extractions: (field_name, format, component, derived_cat_name)
let date_extractions: Vec<(&str, &str, DateComponent, String)> = self
.proposals
.iter()
.filter(|p| {
p.accepted
&& p.kind == FieldKind::TimeCategory
&& p.date_format.is_some()
&& !p.date_components.is_empty()
})
.flat_map(|p| {
let fmt = p.date_format.as_deref().unwrap();
p.date_components.iter().map(move |comp| {
let suffix = match comp {
DateComponent::Year => "Year",
DateComponent::Month => "Month",
DateComponent::Quarter => "Quarter",
};
let derived_name = format!("{}_{}", p.field, suffix);
(p.field.as_str(), fmt, *comp, derived_name)
})
})
.collect();
let mut model = Model::new(&self.model_name);
for cat_proposal in &categories {
@ -139,18 +105,9 @@ impl ImportPipeline {
}
}
// Create derived date-component categories
for (_, _, _, ref derived_name) in &date_extractions {
model.add_category(derived_name)?;
}
// Create label categories (stored but not pivoted by default)
for lab in &labels {
model.add_label_category(&lab.field)?;
}
if !measures.is_empty() {
if let Some(cat) = model.category_mut("_Measure") {
model.add_category("Measure")?;
if let Some(cat) = model.category_mut("Measure") {
for m in &measures {
cat.add_item(&m.field);
}
@ -173,19 +130,7 @@ impl ImportPipeline {
if let Some(cat) = model.category_mut(&cat_proposal.field) {
cat.add_item(&v);
}
coords.push((cat_proposal.field.clone(), v.clone()));
// Extract date components from this field's value
for (field, fmt, comp, ref derived_name) in &date_extractions {
if *field == cat_proposal.field {
if let Some(derived_val) = extract_date_component(&v, fmt, *comp) {
if let Some(cat) = model.category_mut(derived_name) {
cat.add_item(&derived_val);
}
coords.push((derived_name.clone(), derived_val));
}
}
}
coords.push((cat_proposal.field.clone(), v));
} else {
valid = false;
break;
@ -196,47 +141,16 @@ impl ImportPipeline {
continue;
}
// Attach label values as coords (missing labels become "").
for lab in &labels {
let val = map
.get(&lab.field)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
map.get(&lab.field).and_then(|v| {
if v.is_null() {
None
} else {
Some(v.to_string())
}
})
})
.unwrap_or_default();
if let Some(cat) = model.category_mut(&lab.field) {
cat.add_item(&val);
}
coords.push((lab.field.clone(), val));
}
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()));
cell_coords.push(("Measure".to_string(), measure.field.clone()));
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
}
}
}
}
// Parse and add formulas
// Formulas target the "_Measure" category by default.
let formula_cat: String = "_Measure".to_string();
for raw in &self.formulas {
if let Ok(formula) = parse_formula(raw, &formula_cat) {
model.add_formula(formula);
}
}
Ok(model)
}
}
@ -248,8 +162,6 @@ pub enum WizardStep {
Preview,
SelectArrayPath,
ReviewProposals,
ConfigureDates,
DefineFormulas,
NameModel,
Done,
}
@ -265,10 +177,6 @@ pub struct ImportWizard {
pub cursor: usize,
/// One-line message to display at the bottom of the wizard panel.
pub message: Option<String>,
/// Whether we're in formula text-input mode.
pub formula_editing: bool,
/// Buffer for the formula being typed.
pub formula_buffer: String,
}
impl ImportWizard {
@ -288,8 +196,6 @@ impl ImportWizard {
step,
cursor: 0,
message: None,
formula_editing: false,
formula_buffer: String::new(),
}
}
@ -305,15 +211,7 @@ impl ImportWizard {
}
}
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
WizardStep::ReviewProposals => {
if self.has_time_categories() {
WizardStep::ConfigureDates
} else {
WizardStep::DefineFormulas
}
}
WizardStep::ConfigureDates => WizardStep::DefineFormulas,
WizardStep::DefineFormulas => WizardStep::NameModel,
WizardStep::ReviewProposals => WizardStep::NameModel,
WizardStep::NameModel => WizardStep::Done,
WizardStep::Done => WizardStep::Done,
};
@ -321,22 +219,6 @@ impl ImportWizard {
self.message = None;
}
fn has_time_categories(&self) -> bool {
self.pipeline
.proposals
.iter()
.any(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
}
/// Get accepted TimeCategory proposals (for ConfigureDates step).
pub fn time_category_proposals(&self) -> Vec<&FieldProposal> {
self.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some())
.collect()
}
pub fn confirm_path(&mut self) {
if self.cursor < self.pipeline.array_paths.len() {
let path = self.pipeline.array_paths[self.cursor].clone();
@ -351,8 +233,6 @@ impl ImportWizard {
let len = match self.step {
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
WizardStep::ConfigureDates => self.date_config_item_count(),
WizardStep::DefineFormulas => self.pipeline.formulas.len(),
_ => 0,
};
if len == 0 {
@ -395,130 +275,6 @@ impl ImportWizard {
self.pipeline.model_name.pop();
}
// ── Date config ────────────────────────────────────────────────────────────
/// Total number of items in the ConfigureDates list.
/// Each TimeCategory field gets 3 rows (Year, Month, Quarter).
fn date_config_item_count(&self) -> usize {
self.time_category_proposals().len() * 3
}
/// Get the (field_index, component) for the current cursor position.
pub fn date_config_at_cursor(&self) -> Option<(usize, DateComponent)> {
let tc_indices = self.time_category_indices();
if tc_indices.is_empty() {
return None;
}
let field_idx = self.cursor / 3;
let comp_idx = self.cursor % 3;
let component = match comp_idx {
0 => DateComponent::Year,
1 => DateComponent::Month,
_ => DateComponent::Quarter,
};
tc_indices.get(field_idx).map(|&pi| (pi, component))
}
/// Indices into pipeline.proposals for accepted TimeCategory fields.
fn time_category_indices(&self) -> Vec<usize> {
self.pipeline
.proposals
.iter()
.enumerate()
.filter(|(_, p)| {
p.accepted && p.kind == FieldKind::TimeCategory && p.date_format.is_some()
})
.map(|(i, _)| i)
.collect()
}
/// Toggle a date component for the field at the current cursor.
pub fn toggle_date_component(&mut self) {
if let Some((pi, component)) = self.date_config_at_cursor() {
let proposal = &mut self.pipeline.proposals[pi];
if let Some(pos) = proposal
.date_components
.iter()
.position(|c| *c == component)
{
proposal.date_components.remove(pos);
} else {
proposal.date_components.push(component);
}
}
}
// ── Formula editing ────────────────────────────────────────────────────────
/// Buffer for typing a new formula in the DefineFormulas step.
pub fn push_formula_char(&mut self, c: char) {
if !self.formula_editing {
self.formula_editing = true;
self.formula_buffer.clear();
}
self.formula_buffer.push(c);
}
pub fn pop_formula_char(&mut self) {
self.formula_buffer.pop();
}
/// Commit the current formula buffer to the pipeline's formula list.
pub fn confirm_formula(&mut self) {
let text = self.formula_buffer.trim().to_string();
if !text.is_empty() {
self.pipeline.formulas.push(text);
}
self.formula_buffer.clear();
self.formula_editing = false;
self.cursor = self.pipeline.formulas.len().saturating_sub(1);
}
/// Delete the formula at the current cursor position.
pub fn delete_formula(&mut self) {
if self.cursor < self.pipeline.formulas.len() {
self.pipeline.formulas.remove(self.cursor);
if self.cursor > 0 && self.cursor >= self.pipeline.formulas.len() {
self.cursor -= 1;
}
}
}
/// Start editing a new formula.
pub fn start_formula_edit(&mut self) {
self.formula_editing = true;
self.formula_buffer.clear();
}
/// Cancel formula editing.
pub fn cancel_formula_edit(&mut self) {
self.formula_editing = false;
self.formula_buffer.clear();
}
/// Generate sample formulas based on accepted measures.
pub fn sample_formulas(&self) -> Vec<String> {
let measures: Vec<&str> = self
.pipeline
.proposals
.iter()
.filter(|p| p.accepted && p.kind == FieldKind::Measure)
.map(|p| p.field.as_str())
.collect();
let mut samples = Vec::new();
if measures.len() >= 2 {
samples.push(format!("Diff = {} - {}", measures[0], measures[1]));
}
if !measures.is_empty() {
samples.push(format!("Total = SUM({})", measures[0]));
}
if measures.len() >= 2 {
samples.push(format!("Ratio = {} / {}", measures[0], measures[1]));
}
samples
}
// ── Delegate build to pipeline ────────────────────────────────────────────
pub fn build_model(&self) -> Result<Model> {
@ -618,47 +374,7 @@ mod tests {
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 label_fields_imported_as_label_category_coords() {
use crate::model::category::CategoryKind;
// 25 unique descriptions → classified as Label (> CATEGORY_THRESHOLD=20)
let records: Vec<serde_json::Value> = (0..25)
.map(|i| json!({"region": "East", "desc": format!("row-{i}"), "revenue": i as f64}))
.collect();
let raw = serde_json::Value::Array(records);
let p = ImportPipeline::new(raw);
let desc = p.proposals.iter().find(|p| p.field == "desc").unwrap();
assert_eq!(desc.kind, FieldKind::Label);
assert!(desc.accepted, "labels should default to accepted");
let model = p.build_model().unwrap();
// Label field exists as a category with Label kind
let cat = model.category("desc").expect("desc category exists");
assert_eq!(cat.kind, CategoryKind::Label);
// Each record's cell key carries the desc label coord
use crate::model::cell::CellKey;
let k = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
("desc".to_string(), "row-7".to_string()),
("region".to_string(), "East".to_string()),
]);
assert_eq!(model.get_cell(&k).and_then(|v| v.as_f64()), Some(7.0));
}
#[test]
fn label_category_defaults_to_none_axis() {
use crate::view::Axis;
let records: Vec<serde_json::Value> = (0..25)
.map(|i| json!({"region": "East", "desc": format!("r{i}"), "n": 1.0}))
.collect();
let raw = serde_json::Value::Array(records);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let v = model.active_view();
assert_eq!(v.axis_of("desc"), Axis::None);
assert!(model.category("Measure").is_some());
}
#[test]
@ -671,11 +387,11 @@ mod tests {
let model = p.build_model().unwrap();
use crate::model::cell::CellKey;
let k_east = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
("Measure".to_string(), "revenue".to_string()),
("region".to_string(), "East".to_string()),
]);
let k_west = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
("Measure".to_string(), "revenue".to_string()),
("region".to_string(), "West".to_string()),
]);
assert_eq!(
@ -694,432 +410,4 @@ mod tests {
let p = ImportPipeline::new(raw);
assert_eq!(p.model_name, "Imported Model");
}
#[test]
fn build_model_adds_formulas_from_pipeline() {
let raw = json!([
{"region": "East", "revenue": 100.0, "cost": 40.0},
{"region": "West", "revenue": 200.0, "cost": 80.0},
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Profit = revenue - cost".to_string());
let model = p.build_model().unwrap();
// The formula should produce Profit = 60 for East (100-40)
use crate::model::cell::CellKey;
let key = CellKey::new(vec![
("_Measure".to_string(), "Profit".to_string()),
("region".to_string(), "East".to_string()),
]);
let val = model.evaluate(&key).and_then(|v| v.as_f64());
assert_eq!(val, Some(60.0));
}
#[test]
fn build_model_extracts_date_month_component() {
use crate::import::analyzer::DateComponent;
let raw = json!([
{"Date": "01/15/2025", "Amount": 100.0},
{"Date": "01/20/2025", "Amount": 50.0},
{"Date": "02/05/2025", "Amount": 200.0},
]);
let mut p = ImportPipeline::new(raw);
// Enable Month extraction on the Date field
for prop in &mut p.proposals {
if prop.field == "Date" && prop.kind == FieldKind::TimeCategory {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
assert!(model.category("Date_Month").is_some());
let cat = model.category("Date_Month").unwrap();
let items: Vec<&str> = cat.items.keys().map(|s| s.as_str()).collect();
assert!(items.contains(&"2025-01"));
assert!(items.contains(&"2025-02"));
}
// ── ImportWizard tests ────────────────────────────────────────────────
use super::ImportWizard;
use super::WizardStep;
use crate::import::analyzer::DateComponent;
fn sample_wizard() -> ImportWizard {
let raw = json!([
{"region": "East", "product": "Shirts", "revenue": 100.0, "cost": 40.0},
{"region": "West", "product": "Pants", "revenue": 200.0, "cost": 80.0},
]);
ImportWizard::new(raw)
}
#[test]
fn wizard_starts_at_review_proposals_for_flat_array() {
let w = sample_wizard();
assert_eq!(w.step, WizardStep::ReviewProposals);
}
#[test]
fn wizard_starts_at_select_array_path_for_multi_path_object() {
let raw = json!({
"orders": [{"id": 1}],
"products": [{"name": "A"}],
});
let w = ImportWizard::new(raw);
assert_eq!(w.step, WizardStep::SelectArrayPath);
}
#[test]
fn wizard_advance_from_review_proposals_skips_dates_when_none() {
let mut w = sample_wizard();
assert_eq!(w.step, WizardStep::ReviewProposals);
w.advance();
// No time categories → skip ConfigureDates → DefineFormulas
assert_eq!(w.step, WizardStep::DefineFormulas);
}
#[test]
fn wizard_advance_full_sequence() {
let mut w = sample_wizard();
// ReviewProposals → DefineFormulas → NameModel → Done
w.advance();
assert_eq!(w.step, WizardStep::DefineFormulas);
w.advance();
assert_eq!(w.step, WizardStep::NameModel);
w.advance();
assert_eq!(w.step, WizardStep::Done);
// Done stays Done
w.advance();
assert_eq!(w.step, WizardStep::Done);
}
#[test]
fn wizard_move_cursor_clamps() {
let mut w = sample_wizard();
// At ReviewProposals, cursor starts at 0
w.move_cursor(-1);
assert_eq!(w.cursor, 0); // can't go below 0
w.move_cursor(1);
assert_eq!(w.cursor, 1);
// Move way past end
for _ in 0..100 {
w.move_cursor(1);
}
assert!(w.cursor < w.pipeline.proposals.len());
}
#[test]
fn wizard_toggle_proposal() {
let mut w = sample_wizard();
let was_accepted = w.pipeline.proposals[0].accepted;
w.toggle_proposal();
assert_ne!(w.pipeline.proposals[0].accepted, was_accepted);
w.toggle_proposal();
assert_eq!(w.pipeline.proposals[0].accepted, was_accepted);
}
#[test]
fn wizard_cycle_proposal_kind() {
let mut w = sample_wizard();
let original = w.pipeline.proposals[0].kind.clone();
w.cycle_proposal_kind();
assert_ne!(w.pipeline.proposals[0].kind, original);
// Cycle through all 4 kinds back to original
w.cycle_proposal_kind();
w.cycle_proposal_kind();
w.cycle_proposal_kind();
assert_eq!(w.pipeline.proposals[0].kind, original);
}
#[test]
fn wizard_model_name_editing() {
let mut w = sample_wizard();
w.pipeline.model_name.clear();
w.push_name_char('H');
w.push_name_char('i');
assert_eq!(w.pipeline.model_name, "Hi");
w.pop_name_char();
assert_eq!(w.pipeline.model_name, "H");
}
#[test]
fn wizard_confirm_path() {
let raw = json!({
"orders": [{"id": 1, "region": "East", "amount": 10.0}],
"products": [{"name": "A"}],
});
let mut w = ImportWizard::new(raw);
assert_eq!(w.step, WizardStep::SelectArrayPath);
w.confirm_path(); // selects first path
// Should advance past SelectArrayPath
assert_ne!(w.step, WizardStep::SelectArrayPath);
assert!(!w.pipeline.records.is_empty());
}
// ── Formula editing in wizard ───────────────────────────────────────
#[test]
fn wizard_formula_lifecycle() {
let mut w = sample_wizard();
// Go to DefineFormulas
w.advance();
assert_eq!(w.step, WizardStep::DefineFormulas);
// Start editing
w.start_formula_edit();
assert!(w.formula_editing);
// Type formula
for c in "Profit = revenue - cost".chars() {
w.push_formula_char(c);
}
assert_eq!(w.formula_buffer, "Profit = revenue - cost");
// Pop a char
w.pop_formula_char();
assert_eq!(w.formula_buffer, "Profit = revenue - cos");
// Cancel
w.cancel_formula_edit();
assert!(!w.formula_editing);
assert!(w.formula_buffer.is_empty());
assert!(w.pipeline.formulas.is_empty()); // nothing committed
// Start again and confirm
w.start_formula_edit();
for c in "Profit = revenue - cost".chars() {
w.push_formula_char(c);
}
w.confirm_formula();
assert!(!w.formula_editing);
assert_eq!(w.pipeline.formulas.len(), 1);
assert_eq!(w.pipeline.formulas[0], "Profit = revenue - cost");
}
#[test]
fn wizard_delete_formula() {
let mut w = sample_wizard();
w.pipeline.formulas.push("A = B + C".to_string());
w.pipeline.formulas.push("D = E + F".to_string());
w.cursor = 1;
w.delete_formula();
assert_eq!(w.pipeline.formulas.len(), 1);
assert_eq!(w.pipeline.formulas[0], "A = B + C");
assert_eq!(w.cursor, 0); // adjusted down
}
#[test]
fn wizard_delete_formula_at_zero() {
let mut w = sample_wizard();
w.pipeline.formulas.push("A = B + C".to_string());
w.cursor = 0;
w.delete_formula();
assert!(w.pipeline.formulas.is_empty());
assert_eq!(w.cursor, 0);
}
#[test]
fn wizard_confirm_empty_formula_is_noop() {
let mut w = sample_wizard();
w.start_formula_edit();
w.confirm_formula(); // empty buffer
assert!(w.pipeline.formulas.is_empty());
}
// ── Sample formulas ─────────────────────────────────────────────────
#[test]
fn sample_formulas_with_two_measures() {
let w = sample_wizard();
let samples = w.sample_formulas();
// Should have Diff, Total, and Ratio suggestions
assert!(samples.len() >= 2);
assert!(samples.iter().any(|s| s.contains("Diff")));
assert!(samples.iter().any(|s| s.contains("Total")));
}
#[test]
fn sample_formulas_with_one_measure() {
let raw = json!([
{"region": "East", "revenue": 100.0},
]);
let w = ImportWizard::new(raw);
let samples = w.sample_formulas();
assert!(samples.iter().any(|s| s.contains("Total")));
// No Diff or Ratio with only one measure
assert!(!samples.iter().any(|s| s.contains("Diff")));
}
#[test]
fn sample_formulas_with_no_measures() {
let raw = json!([
{"region": "East", "product": "Shirts"},
]);
let w = ImportWizard::new(raw);
let samples = w.sample_formulas();
assert!(samples.is_empty());
}
// ── Preview summary ─────────────────────────────────────────────────
#[test]
fn preview_summary_for_array() {
let raw = json!([
{"region": "East", "revenue": 100.0},
]);
let p = ImportPipeline::new(raw);
let s = p.preview_summary();
assert!(s.contains("1 records"));
assert!(s.contains("region"));
}
#[test]
fn preview_summary_for_object() {
let raw = json!({
"data": [{"x": 1}],
"meta": {"version": 1},
});
let p = ImportPipeline::new(raw);
let s = p.preview_summary();
assert!(s.contains("Object"));
assert!(s.contains("data"));
}
// ── Date config ─────────────────────────────────────────────────────
#[test]
fn wizard_date_config_toggle() {
let raw = json!([
{"Date": "01/15/2025", "Amount": 100.0},
{"Date": "02/15/2025", "Amount": 200.0},
]);
let mut w = ImportWizard::new(raw);
// Enable the Date field as a TimeCategory (should already be detected)
let has_time = w.has_time_categories();
if has_time {
// Advance to ConfigureDates
w.advance();
assert_eq!(w.step, WizardStep::ConfigureDates);
// Toggle Year component (cursor 0 = Year of first time field)
let had_year_before = {
let tc = w.time_category_proposals();
!tc.is_empty()
&& tc[0]
.date_components
.iter()
.any(|c| *c == DateComponent::Year)
};
w.toggle_date_component();
let has_year_after = {
let tc = w.time_category_proposals();
!tc.is_empty()
&& tc[0]
.date_components
.iter()
.any(|c| *c == DateComponent::Year)
};
assert_ne!(had_year_before, has_year_after);
}
}
#[test]
fn wizard_date_config_at_cursor_mapping() {
let raw = json!([
{"Date": "01/15/2025", "Amount": 100.0},
{"Date": "02/15/2025", "Amount": 200.0},
]);
let mut w = ImportWizard::new(raw);
if w.has_time_categories() {
w.advance();
// cursor 0 → Year, cursor 1 → Month, cursor 2 → Quarter
w.cursor = 0;
let (_, comp) = w.date_config_at_cursor().unwrap();
assert_eq!(comp, DateComponent::Year);
w.cursor = 1;
let (_, comp) = w.date_config_at_cursor().unwrap();
assert_eq!(comp, DateComponent::Month);
w.cursor = 2;
let (_, comp) = w.date_config_at_cursor().unwrap();
assert_eq!(comp, DateComponent::Quarter);
}
}
// ── Edge cases in build_model ───────────────────────────────────────
#[test]
fn build_model_record_with_missing_category_value_skipped() {
// If a record is missing a category field, it should be skipped
let raw = json!([
{"region": "East", "revenue": 100.0},
{"revenue": 200.0}, // missing "region"
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
// Only one cell should exist (the East record)
use crate::model::cell::CellKey;
let k = CellKey::new(vec![
("_Measure".to_string(), "revenue".to_string()),
("region".to_string(), "East".to_string()),
]);
assert!(model.get_cell(&k).is_some());
}
#[test]
fn build_model_with_integer_category_values() {
// Non-string JSON values used as categories should be stringified.
// Use many repeated string values so "id" gets classified as Category,
// plus a numeric field that triggers Measure.
let raw = json!([
{"id": "A", "type": "x", "value": 100.0},
{"id": "B", "type": "x", "value": 200.0},
{"id": "A", "type": "y", "value": 150.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
let cat = model.category("id").expect("id should be a category");
let items: Vec<&str> = cat.ordered_item_names().into_iter().collect();
assert!(items.contains(&"A"));
assert!(items.contains(&"B"));
}
#[test]
fn build_model_formulas_without_measure_category() {
// NOTE: When there are no measures, formula_cat falls back to
// the first category key, which may include virtual categories.
// This mirrors the CommitFormula bug (improvise-79u).
let raw = json!([
{"region": "East", "product": "Shirts"},
{"region": "West", "product": "Pants"},
]);
let mut p = ImportPipeline::new(raw);
p.formulas.push("Test = A + B".to_string());
let model = p.build_model().unwrap();
// Formula should still be added (even if target category is suboptimal)
// The formula may fail to parse against a non-measure category, which is OK
// Just ensure build_model doesn't panic
assert!(model.category("region").is_some());
}
#[test]
fn build_model_date_components_appear_in_cell_keys() {
use crate::import::analyzer::DateComponent;
use crate::model::cell::CellKey;
let raw = json!([
{"Date": "03/31/2026", "Amount": 100.0},
]);
let mut p = ImportPipeline::new(raw);
for prop in &mut p.proposals {
if prop.field == "Date" {
prop.date_components.push(DateComponent::Month);
}
}
let model = p.build_model().unwrap();
let key = CellKey::new(vec![
("Date".to_string(), "03/31/2026".to_string()),
("Date_Month".to_string(), "2026-03".to_string()),
("_Measure".to_string(), "Amount".to_string()),
]);
assert_eq!(model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0));
}
}

View File

@ -1,6 +1,4 @@
mod command;
mod draw;
mod format;
mod formula;
mod import;
mod model;
@ -8,367 +6,214 @@ mod persistence;
mod ui;
mod view;
use crate::import::csv_parser::csv_path_p;
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use enum_dispatch::enum_dispatch;
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Paragraph},
Frame, Terminal,
};
use draw::run_tui;
use model::Model;
use serde_json::Value;
use ui::app::{App, AppMode};
use ui::category_panel::CategoryPanel;
use ui::formula_panel::FormulaPanel;
use ui::grid::GridWidget;
use ui::help::HelpWidget;
use ui::import_wizard_ui::ImportWizardWidget;
use ui::tile_bar::TileBar;
use ui::view_panel::ViewPanel;
fn main() -> Result<()> {
let cli = Cli::parse();
let cmd = cli.command.unwrap_or(Commands::Open(OpenTui));
cmd.run(cli.file)
let args: Vec<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
#[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 {
fn run(self, model_file: Option<PathBuf>) -> Result<()>;
fn run(self: Box<Self>) -> Result<()>;
}
#[derive(Subcommand)]
#[enum_dispatch(Runnable)]
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),
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_path: Option<PathBuf>,
}
#[derive(clap::Args)]
struct ImportArgs {
/// Files to import (multiple CSVs merge with a "File" category)
files: Vec<PathBuf>,
impl Runnable for CmdLineArgs {
fn run(self: Box<Self>) -> Result<()> {
// Load or create model
let model = get_initial_model(&self.file_path)?;
/// Mark field as category dimension (repeatable)
#[arg(long)]
category: Vec<String>,
/// Mark field as numeric measure (repeatable)
#[arg(long)]
measure: Vec<String>,
/// Mark field as time/date category (repeatable)
#[arg(long)]
time: Vec<String>,
/// Skip/exclude a field from import (repeatable)
#[arg(long)]
skip: Vec<String>,
/// Extract date component, e.g. "Date:Month" (repeatable)
#[arg(long)]
extract: Vec<String>,
/// Set category axis, e.g. "Payee:row" (repeatable)
#[arg(long)]
axis: Vec<String>,
/// Add formula, e.g. "Profit = Revenue - Cost" (repeatable)
#[arg(long)]
formula: Vec<String>,
/// Model name (default: "Imported Model")
#[arg(long)]
name: Option<String>,
/// Skip the interactive wizard
#[arg(long)]
no_wizard: bool,
/// Save to file instead of opening TUI
#[arg(short, long)]
output: Option<PathBuf>,
}
#[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 model = get_initial_model(&model_file)?;
run_tui(model, 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(model: &mut Model, axes: &[(String, String)]) {
use view::Axis;
let view = model.active_view_mut();
for (cat, axis_str) in axes {
let axis = match axis_str.to_lowercase().as_str() {
"row" => Axis::Row,
"column" | "col" => Axis::Column,
"page" => Axis::Page,
"none" => Axis::None,
_ => continue,
};
view.set_axis(cat, axis);
}
}
fn run_headless_import(
import_value: Value,
config: &ImportConfig,
output: Option<PathBuf>,
model_file: Option<PathBuf>,
) -> Result<()> {
let mut pipeline = import::wizard::ImportPipeline::new(import_value);
apply_config_to_pipeline(&mut pipeline, config);
let mut model = pipeline.build_model()?;
model.normalize_view_state();
apply_axis_overrides(&mut model, &config.axes);
if let Some(path) = output.or(model_file) {
persistence::save(&model, &path)?;
eprintln!("Saved to {}", path.display());
} else {
eprintln!("No output path specified; use -o <path> or provide a model file");
}
Ok(())
}
fn run_wizard_import(
import_value: Value,
_config: &ImportConfig,
model_file: Option<PathBuf>,
) -> Result<()> {
let model = get_initial_model(&model_file)?;
// Pre-configure will happen inside the TUI via the wizard
// For now, pass import_value and let the wizard handle it
// TODO: pass config to wizard for pre-population
run_tui(model, model_file, Some(import_value))
}
// ── Import data loading ──────────────────────────────────────────────────────
fn get_import_data(paths: &[PathBuf]) -> Option<Value> {
let all_csv = paths.iter().all(|p| csv_path_p(p));
if paths.len() > 1 {
if !all_csv {
eprintln!("Multi-file import only supports CSV files");
return None;
}
match crate::import::csv_parser::merge_csvs(paths) {
Ok(records) => Some(Value::Array(records)),
Err(e) => {
eprintln!("CSV merge error: {e}");
None
}
}
} else {
let path = &paths[0];
// Pre-TUI import: parse JSON or CSV and open wizard
let import_value = if let Some(ref path) = self.import_path {
match std::fs::read_to_string(path) {
Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display());
None
return Ok(());
}
Ok(content) => {
if csv_path_p(path) {
match crate::import::csv_parser::parse_csv(path) {
Ok(records) => Some(Value::Array(records)),
if path.to_string_lossy().ends_with(".csv") {
// Parse CSV and wrap as JSON array
match crate::import::csv_parser::parse_csv(&path.to_string_lossy()) {
Ok(records) => Some(serde_json::Value::Array(records)),
Err(e) => {
eprintln!("CSV parse error: {e}");
None
return Ok(());
}
}
} else {
match serde_json::from_str::<Value>(&content) {
// Parse JSON
match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => {
eprintln!("JSON parse error: {e}");
None
return Ok(());
}
Ok(json) => Some(json),
}
}
}
}
} else {
None
};
run_tui(model, self.file_path, import_value)
}
}
// ── Headless command execution ───────────────────────────────────────────────
struct HeadlessArgs {
file_path: Option<PathBuf>,
commands: Vec<String>,
script: Option<PathBuf>,
}
fn run_headless_commands(cmds: &[String], file: &Option<PathBuf>) -> Result<()> {
use crossterm::event::{KeyCode, KeyModifiers};
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 model = get_initial_model(file)?;
let mut app = ui::app::App::new(model, file.clone());
let mut exit_code = 0;
for line in cmds {
match command::parse_line(line) {
Ok(parsed_cmds) => {
for cmd in &parsed_cmds {
let effects = {
let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE);
cmd.execute(&ctx)
};
app.apply_effects(effects);
}
}
for raw_cmd in &cmds {
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
Ok(c) => c,
Err(e) => {
eprintln!("Parse error: {e}");
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
println!("{}", serde_json::to_string(&r)?);
exit_code = 1;
continue;
}
};
let result = command::dispatch(&mut model, &parsed);
if !result.ok {
exit_code = 1;
}
}
println!("{}", serde_json::to_string(&result)?);
}
if let Some(path) = file {
persistence::save(&app.model, path)?;
if let Some(path) = self.file_path {
persistence::save(&mut model, &path)?;
}
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;
impl Runnable for HelpArgs {
fn run(self: Box<Self>) -> Result<()> {
println!("improvise — multi-dimensional data modeling TUI\n");
println!("USAGE:");
println!(" improvise [file.improv] Open or create a model");
println!(" improvise --import data.json Import JSON (or CSV) 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(())
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
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 {
@ -389,3 +234,367 @@ fn get_initial_model(file_path: &Option<PathBuf>) -> Result<Model> {
Ok(Model::new("New Model"))
}
}
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_value: Option<serde_json::Value>,
) -> Result<()> {
let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path);
if let Some(json) = import_value {
app.start_import_wizard(json);
}
loop {
tui_context.terminal.draw(|f| draw(f, &app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key)?;
}
}
app.autosave_if_needed();
if matches!(app.mode, AppMode::Quit) {
break;
}
}
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn fill_line(left: String, right: &str, width: u16) -> String {
let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len()));
format!("{left}{pad}{right}")
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w, h)
}
fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect {
f.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(popup);
f.render_widget(block, popup);
inner
}
fn mode_name(mode: &AppMode) -> &'static str {
match mode {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // title bar
Constraint::Min(0), // content
Constraint::Length(1), // tile bar
Constraint::Length(1), // status / command bar
])
.split(size);
draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
// Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) {
f.render_widget(HelpWidget, size);
}
if matches!(app.mode, AppMode::ImportWizard) {
if let Some(wizard) = &app.wizard {
f.render_widget(ImportWizardWidget::new(wizard), size);
}
}
if matches!(app.mode, AppMode::ExportPrompt { .. }) {
draw_export_prompt(f, size, app);
}
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1]);
}
}
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
let dirty = if app.dirty { " [+]" } else { "" };
let file = app
.file_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| format!(" ({n})"))
.unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit ";
let line = fill_line(title, right, area.width);
f.render_widget(
Paragraph::new(line).style(
Style::default()
.fg(Color::Black)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let grid_area;
if side_open {
let side_w = 32u16;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area);
grid_area = chunks[0];
let side = chunks[1];
let panel_count = [
app.formula_panel_open,
app.category_panel_open,
app.view_panel_open,
]
.iter()
.filter(|&&b| b)
.count() as u16;
let ph = side.height / panel_count.max(1);
let mut y = side.y;
if app.formula_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
a,
);
y += ph;
}
if app.category_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor),
a,
);
y += ph;
}
if app.view_panel_open {
let a = Rect::new(side.x, y, side.width, ph);
f.render_widget(
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
a,
);
}
} else {
grid_area = area;
}
f.render_widget(
GridWidget::new(&app.model, &app.mode, &app.search_query),
grid_area,
);
}
fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode), area);
}
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
match app.mode {
AppMode::CommandMode { ref buffer } => draw_command_bar(f, area, buffer),
_ => draw_status(f, area, app),
}
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode {
format!(" /{}", app.search_query)
} else {
String::new()
};
let msg = if !app.status_msg.is_empty() {
app.status_msg.as_str()
} else {
app.hint_text()
};
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let line = fill_line(left, &view_badge, area.width);
f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
}
fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
f.render_widget(
Paragraph::new(format!(":{buffer}"))
.style(Style::default().fg(Color::White).bg(Color::Black)),
area,
);
}
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let popup = centered_popup(area, 64, 3);
let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner,
);
}
fn draw_welcome(f: &mut Frame, area: Rect) {
let popup = centered_popup(area, 58, 20);
let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
let lines: &[(&str, Style)] = &[
(
"Multi-dimensional data modeling — in your terminal.",
Style::default().fg(Color::White),
),
("", Style::default()),
(
"Getting started",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan),
),
(
":add-cat <name> Add a category (dimension)",
Style::default().fg(Color::Cyan),
),
(
":add-item <cat> <name> Add an item to a category",
Style::default().fg(Color::Cyan),
),
(
":formula <cat> <expr> Add a formula, e.g.:",
Style::default().fg(Color::Cyan),
),
(
" Profit = Revenue - Cost",
Style::default().fg(Color::Green),
),
(
":w <file.improv> Save your model",
Style::default().fg(Color::Cyan),
),
("", Style::default()),
(
"Navigation",
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
("", Style::default()),
(
"F C V Open panels (Formulas/Categories/Views)",
Style::default(),
),
(
"T Tile-select: pivot rows ↔ cols ↔ page",
Style::default(),
),
("i Enter Edit a cell", Style::default()),
(
"[ ] Cycle the page-axis filter",
Style::default(),
),
(
"? or :help Full key reference",
Style::default(),
),
(":q Quit", Style::default()),
];
for (i, (text, style)) in lines.iter().enumerate() {
if i >= inner.height as usize {
break;
}
f.render_widget(
Paragraph::new(*text).style(*style),
Rect::new(
inner.x + 1,
inner.y + i as u16,
inner.width.saturating_sub(2),
1,
),
);
}
}

View File

@ -48,38 +48,6 @@ 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)]
pub struct Category {
pub id: CategoryId,
@ -90,9 +58,6 @@ pub struct Category {
pub groups: Vec<Group>,
/// Next item id counter
next_item_id: ItemId,
/// Whether this is a regular or virtual category
#[serde(default)]
pub kind: CategoryKind,
}
impl Category {
@ -103,15 +68,9 @@ impl Category {
items: IndexMap::new(),
groups: Vec::new(),
next_item_id: 0,
kind: CategoryKind::Regular,
}
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
}
pub fn add_item(&mut self, name: impl Into<String>) -> ItemId {
let name = name.into();
if let Some(item) = self.items.get(&name) {
@ -123,10 +82,6 @@ impl Category {
id
}
pub fn remove_item(&mut self, name: &str) {
self.items.shift_remove(name);
}
pub fn add_item_in_group(
&mut self,
name: impl Into<String>,
@ -150,10 +105,31 @@ impl Category {
}
}
// pub fn item_by_name(&self, name: &str) -> Option<&Item> {
// self.items.get(name)
// }
// pub fn item_index(&self, name: &str) -> Option<usize> {
// self.items.get_index_of(name)
// }
/// Returns item names in order, grouped hierarchically
pub fn ordered_item_names(&self) -> Vec<&str> {
self.items.keys().map(|s| s.as_str()).collect()
}
/// Returns unique group names in insertion order, derived from item.group fields.
pub fn top_level_groups(&self) -> Vec<&str> {
let mut seen = Vec::new();
for item in self.items.values() {
if let Some(g) = &item.group {
if !seen.contains(&g.as_str()) {
seen.push(g.as_str());
}
}
}
seen
}
}
#[cfg(test)]
@ -209,6 +185,30 @@ mod tests {
assert_eq!(c.groups.len(), 1);
}
#[test]
fn top_level_groups_returns_unique_groups_in_insertion_order() {
let mut c = cat();
c.add_item_in_group("Jan", "Q1");
c.add_item_in_group("Feb", "Q1");
c.add_item_in_group("Apr", "Q2");
assert_eq!(c.top_level_groups(), vec!["Q1", "Q2"]);
}
#[test]
fn top_level_groups_empty_for_ungrouped_category() {
let mut c = cat();
c.add_item("East");
c.add_item("West");
assert!(c.top_level_groups().is_empty());
}
#[test]
fn top_level_groups_only_reflects_item_group_fields_not_groups_vec() {
let mut c = cat();
c.add_group(Group::new("Orphan"));
assert!(c.top_level_groups().is_empty());
}
#[test]
fn item_index_reflects_insertion_order() {
let mut c = cat();

View File

@ -1,7 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::symbol::{Symbol, SymbolTable};
use std::collections::HashMap;
/// A cell key is a sorted vector of (category_name, item_name) pairs.
/// Sorted by category name for canonical form.
@ -43,7 +41,6 @@ impl CellKey {
)
}
#[allow(dead_code)]
pub fn matches_partial(&self, partial: &[(String, String)]) -> bool {
partial
.iter()
@ -62,21 +59,15 @@ impl std::fmt::Display for CellKey {
pub enum CellValue {
Number(f64),
Text(String),
/// Evaluation error (circular reference, depth overflow, etc.)
Error(String),
}
impl CellValue {
pub fn as_f64(&self) -> Option<f64> {
match self {
CellValue::Number(n) => Some(*n),
_ => None,
CellValue::Text(_) => None,
}
}
pub fn is_error(&self) -> bool {
matches!(self, CellValue::Error(_))
}
}
impl std::fmt::Display for CellValue {
@ -90,26 +81,15 @@ impl std::fmt::Display for CellValue {
}
}
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
/// to implement the `Serialize`-as-string requirement for JSON object keys.
#[derive(Debug, Clone, Default)]
pub struct DataStore {
/// Primary storage — interned keys for O(1) hash/compare.
cells: HashMap<InternedKey, CellValue>,
/// String interner — all category/item names are interned here.
pub symbols: SymbolTable,
/// Secondary index: interned (category, item) → set of interned keys.
index: HashMap<(Symbol, Symbol), HashSet<InternedKey>>,
cells: HashMap<CellKey, CellValue>,
}
impl Serialize for DataStore {
@ -117,8 +97,7 @@ impl Serialize for DataStore {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(self.cells.len()))?;
for (k, v) in &self.cells {
let cell_key = self.to_cell_key(k);
seq.serialize_element(&(cell_key, v))?;
seq.serialize_element(&(k, v))?;
}
seq.end()
}
@ -127,11 +106,8 @@ impl Serialize for DataStore {
impl<'de> Deserialize<'de> for DataStore {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let pairs: Vec<(CellKey, CellValue)> = Vec::deserialize(d)?;
let mut store = DataStore::default();
for (key, value) in pairs {
store.set(key, value);
}
Ok(store)
let cells: HashMap<CellKey, CellValue> = pairs.into_iter().collect();
Ok(DataStore { cells })
}
}
@ -140,143 +116,27 @@ impl DataStore {
Self::default()
}
/// Intern a CellKey into an InternedKey.
pub fn intern_key(&mut self, key: &CellKey) -> InternedKey {
InternedKey(self.symbols.intern_coords(&key.0))
}
/// Convert an InternedKey back to a CellKey (string form).
pub fn to_cell_key(&self, ikey: &InternedKey) -> CellKey {
CellKey(
ikey.0
.iter()
.map(|(c, i)| {
(
self.symbols.resolve(*c).to_string(),
self.symbols.resolve(*i).to_string(),
)
})
.collect(),
)
}
pub fn set(&mut self, key: CellKey, value: CellValue) {
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);
self.cells.insert(key, value);
}
pub fn get(&self, key: &CellKey) -> Option<&CellValue> {
let ikey = self.lookup_key(key)?;
self.cells.get(&ikey)
self.cells.get(key)
}
/// Look up an InternedKey for a CellKey without interning new symbols.
fn lookup_key(&self, key: &CellKey) -> Option<InternedKey> {
let pairs: Option<Vec<(Symbol, Symbol)>> = key
.0
.iter()
.map(|(c, i)| Some((self.symbols.get(c)?, self.symbols.get(i)?)))
.collect();
pairs.map(InternedKey)
}
/// Iterate over all cells, yielding (CellKey, &CellValue) pairs.
pub fn iter_cells(&self) -> impl Iterator<Item = (CellKey, &CellValue)> {
self.cells.iter().map(|(k, v)| (self.to_cell_key(k), v))
pub fn cells(&self) -> &HashMap<CellKey, CellValue> {
&self.cells
}
pub fn remove(&mut self, key: &CellKey) {
let Some(ikey) = self.lookup_key(key) else {
return;
};
if self.cells.remove(&ikey).is_some() {
for pair in &ikey.0 {
if let Some(set) = self.index.get_mut(pair) {
set.remove(&ikey);
}
}
}
self.cells.remove(key);
}
/// Values of all cells where every coordinate in `partial` matches.
/// Hot path: avoids allocating CellKey for each result.
pub fn matching_values(&self, partial: &[(String, String)]) -> Vec<&CellValue> {
if partial.is_empty() {
return self.cells.values().collect();
}
// Intern the partial key (lookup only, no new symbols)
let interned_partial: Vec<(Symbol, Symbol)> = partial
/// All cells where partial coords match
pub fn matching_cells(&self, partial: &[(String, String)]) -> Vec<(&CellKey, &CellValue)> {
self.cells
.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| 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))
})
.filter(|(key, _)| key.matches_partial(partial))
.collect()
}
}
@ -425,7 +285,7 @@ mod data_store {
let k = key(&[("Region", "East")]);
store.set(k.clone(), CellValue::Number(5.0));
store.remove(&k);
assert!(store.iter_cells().next().is_none());
assert!(store.cells().is_empty());
}
#[test]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,124 +0,0 @@
// ── .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 ~
blank_lines ~
version_line ~
model_name ~
initial_view? ~
section* ~
EOI
}
version_line = { "v" ~ rest_of_line ~ 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* }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,158 +0,0 @@
use crate::model::Model;
use std::collections::HashSet;
/// A flattened entry in the category panel tree.
#[derive(Debug, Clone)]
pub enum CatTreeEntry {
/// Category header row: name, item count, expanded?
Category {
name: String,
item_count: usize,
expanded: bool,
},
/// Item row under a category
Item { cat_name: String, item_name: String },
}
impl CatTreeEntry {
/// The category this entry belongs to.
pub fn cat_name(&self) -> &str {
match self {
CatTreeEntry::Category { name, .. } => name,
CatTreeEntry::Item { cat_name, .. } => cat_name,
}
}
}
/// Build the flattened tree of categories and their items.
pub fn build_cat_tree(model: &Model, expanded: &HashSet<String>) -> Vec<CatTreeEntry> {
let mut entries = Vec::new();
for cat_name in model.category_names() {
let cat = model.category(cat_name);
let item_count = cat.map(|c| c.items.len()).unwrap_or(0);
let is_expanded = expanded.contains(cat_name);
entries.push(CatTreeEntry::Category {
name: cat_name.to_string(),
item_count,
expanded: is_expanded,
});
if is_expanded {
if let Some(cat) = cat {
for item_name in cat.ordered_item_names() {
entries.push(CatTreeEntry::Item {
cat_name: cat_name.to_string(),
item_name: item_name.to_string(),
});
}
}
}
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
fn make_model_with_categories(cats: &[(&str, &[&str])]) -> Model {
let mut m = Model::new("Test");
for &(cat_name, items) in cats {
m.add_category(cat_name).unwrap();
let cat = m.category_mut(cat_name).unwrap();
for &item in items {
cat.add_item(item);
}
}
m
}
#[test]
fn empty_model_has_only_virtual_categories() {
let m = Model::new("Test");
let tree = build_cat_tree(&m, &HashSet::new());
// Virtual categories (_Index, _Dim) should appear
let names: Vec<&str> = tree.iter().map(|e| e.cat_name()).collect();
assert!(names.contains(&"_Index"));
assert!(names.contains(&"_Dim"));
}
#[test]
fn collapsed_category_shows_header_only() {
let m = make_model_with_categories(&[("Region", &["North", "South"])]);
let tree = build_cat_tree(&m, &HashSet::new());
let region_entries: Vec<_> = tree.iter().filter(|e| e.cat_name() == "Region").collect();
assert_eq!(region_entries.len(), 1); // just the header
assert!(matches!(
region_entries[0],
CatTreeEntry::Category {
expanded: false,
item_count: 2,
..
}
));
}
#[test]
fn expanded_category_shows_items() {
let m = make_model_with_categories(&[("Region", &["North", "South"])]);
let mut expanded = HashSet::new();
expanded.insert("Region".to_string());
let tree = build_cat_tree(&m, &expanded);
let region_entries: Vec<_> = tree.iter().filter(|e| e.cat_name() == "Region").collect();
// Header + 2 items
assert_eq!(region_entries.len(), 3);
assert!(matches!(
region_entries[0],
CatTreeEntry::Category { expanded: true, .. }
));
assert!(matches!(region_entries[1], CatTreeEntry::Item { .. }));
assert!(matches!(region_entries[2], CatTreeEntry::Item { .. }));
}
#[test]
fn mixed_expanded_and_collapsed() {
let m = make_model_with_categories(&[
("Region", &["North", "South"]),
("Product", &["Shirts", "Pants", "Hats"]),
]);
let mut expanded = HashSet::new();
expanded.insert("Product".to_string());
let tree = build_cat_tree(&m, &expanded);
let region_items: Vec<_> = tree
.iter()
.filter(|e| e.cat_name() == "Region" && matches!(e, CatTreeEntry::Item { .. }))
.collect();
let product_items: Vec<_> = tree
.iter()
.filter(|e| e.cat_name() == "Product" && matches!(e, CatTreeEntry::Item { .. }))
.collect();
assert_eq!(region_items.len(), 0); // collapsed
assert_eq!(product_items.len(), 3); // expanded
}
#[test]
fn cat_name_works_for_both_variants() {
let header = CatTreeEntry::Category {
name: "Region".into(),
item_count: 2,
expanded: false,
};
let item = CatTreeEntry::Item {
cat_name: "Region".into(),
item_name: "North".into(),
};
assert_eq!(header.cat_name(), "Region");
assert_eq!(item.cat_name(), "Region");
}
#[test]
fn expanding_nonexistent_category_is_harmless() {
let m = Model::new("Test");
let mut expanded = HashSet::new();
expanded.insert("DoesNotExist".to_string());
let tree = build_cat_tree(&m, &expanded);
// Should just have virtual categories, no crash
assert!(!tree.is_empty());
}
}

View File

@ -2,12 +2,11 @@ use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
use crate::ui::panel::PanelContent;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (&'static str, Color) {
@ -15,51 +14,97 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
Axis::Row => ("Row ↕", Color::Green),
Axis::Column => ("Col ↔", Color::Blue),
Axis::Page => ("Page ☰", Color::Magenta),
Axis::None => ("None ∅", Color::DarkGray),
}
}
pub struct CategoryContent<'a> {
model: &'a Model,
tree: Vec<CatTreeEntry>,
pub struct CategoryPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> CategoryContent<'a> {
pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet<String>) -> Self {
let tree = build_cat_tree(model, expanded);
Self { model, tree }
}
}
impl PanelContent for CategoryContent<'_> {
fn is_active(&self, mode: &AppMode) -> bool {
matches!(
impl<'a> CategoryPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self {
model,
mode,
AppMode::CategoryPanel | AppMode::ItemAdd { .. } | AppMode::CategoryAdd { .. }
cursor,
}
}
}
impl<'a> Widget for CategoryPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. });
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
let (border_color, title) = if is_cat_add {
(
Color::Yellow,
" Categories — New category (Enter:add Esc:done) ",
)
}
} else if is_item_add {
(
Color::Green,
" Categories — Adding items (Enter:add Esc:done) ",
)
} else if is_active {
(Color::Cyan, " Categories n:new a:add-items Space:axis ")
} else {
(Color::DarkGray, " Categories ")
};
fn active_color(&self) -> Color {
Color::Cyan
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(title);
let inner = block.inner(area);
block.render(area, buf);
fn title(&self) -> &str {
" Categories "
}
fn item_count(&self) -> usize {
self.tree.len()
}
fn empty_message(&self) -> &str {
"(no categories — use :add-cat <name>)"
}
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
let y = inner.y + index as u16;
let view = self.model.active_view();
let base_style = if is_selected {
let cat_names: Vec<&str> = self.model.category_names();
if cat_names.is_empty() {
buf.set_string(
inner.x,
inner.y,
"(no categories — use :add-cat <name>)",
Style::default().fg(Color::DarkGray),
);
return;
}
// How many rows for the list vs the prompt at bottom
let prompt_rows = if is_item_add { 2u16 } else { 0 };
let list_height = inner.height.saturating_sub(prompt_rows);
for (i, cat_name) in cat_names.iter().enumerate() {
if i as u16 >= list_height {
break;
}
let y = inner.y + i as u16;
let (axis_str, axis_color) = axis_display(view.axis_of(cat_name));
let item_count = self
.model
.category(cat_name)
.map(|c| c.items.len())
.unwrap_or(0);
// Highlight the selected category both in CategoryPanel and ItemAdd modes
let is_selected_cat = if is_item_add {
if let AppMode::ItemAdd { category, .. } = self.mode {
*cat_name == category.as_str()
} else {
false
}
} else {
i == self.cursor && is_active
};
let base_style = if is_selected_cat {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
@ -68,20 +113,12 @@ impl PanelContent for CategoryContent<'_> {
Style::default()
};
if is_selected {
if is_selected_cat {
let fill = " ".repeat(inner.width as usize);
buf.set_string(inner.x, y, &fill, base_style);
}
match &self.tree[index] {
CatTreeEntry::Category {
name,
item_count,
expanded,
} => {
let indicator = if *expanded { "" } else { "" };
let (axis_str, axis_color) = axis_display(view.axis_of(name));
let name_part = format!("{indicator} {name} ({item_count})");
let name_part = format!(" {cat_name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style);
@ -90,7 +127,7 @@ impl PanelContent for CategoryContent<'_> {
inner.x + name_part.len() as u16,
y,
&axis_part,
if is_selected {
if is_selected_cat {
base_style
} else {
Style::default().fg(axis_color)
@ -98,10 +135,29 @@ impl PanelContent for CategoryContent<'_> {
);
}
}
CatTreeEntry::Item { item_name, .. } => {
let label = format!(" · {item_name}");
buf.set_string(inner.x, y, &label, base_style);
// Inline prompt at the bottom for CategoryAdd or ItemAdd
let (prompt_color, prompt_text) = match self.mode {
AppMode::CategoryAdd { buffer } => (Color::Yellow, format!(" + category: {buffer}")),
AppMode::ItemAdd { buffer, .. } => (Color::Green, format!(" + item: {buffer}")),
_ => return,
};
let sep_y = inner.y + list_height;
let prompt_y = sep_y + 1;
if sep_y < inner.y + inner.height {
let sep = "".repeat(inner.width as usize);
buf.set_string(inner.x, sep_y, &sep, Style::default().fg(prompt_color));
}
if prompt_y < inner.y + inner.height {
buf.set_string(
inner.x,
prompt_y,
&prompt_text,
Style::default()
.fg(prompt_color)
.add_modifier(Modifier::BOLD),
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,46 +2,64 @@ use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::panel::PanelContent;
pub struct FormulaContent<'a> {
pub struct FormulaPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> FormulaContent<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
impl<'a> FormulaPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self {
model,
mode,
cursor,
}
}
}
impl PanelContent for FormulaContent<'_> {
fn is_active(&self, mode: &AppMode) -> bool {
matches!(mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. })
impl<'a> Widget for FormulaPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(
self.mode,
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
);
let border_style = if is_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Formulas [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let formulas = self.model.formulas();
if formulas.is_empty() {
buf.set_string(
inner.x,
inner.y,
"(no formulas — press 'n' to add)",
Style::default().fg(Color::DarkGray),
);
return;
}
fn active_color(&self) -> Color {
Color::Yellow
for (i, formula) in formulas.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height {
break;
}
fn title(&self) -> &str {
" Formulas [n]ew [d]elete "
}
fn item_count(&self) -> usize {
self.model.formulas().len()
}
fn empty_message(&self) -> &str {
"(no formulas — press 'n' to add)"
}
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
let formula = &self.model.formulas()[index];
let is_selected = i == self.cursor && is_active;
let style = if is_selected {
Style::default()
.fg(Color::Black)
@ -50,32 +68,27 @@ impl PanelContent for FormulaContent<'_> {
} else {
Style::default().fg(Color::Green)
};
let text = format!(" {}", formula.raw);
let text = format!(" {} = {:?}", formula.target, formula.raw);
let truncated = if text.len() > inner.width as usize {
format!("{}", &text[..inner.width as usize - 1])
} else {
text
};
buf.set_string(inner.x, inner.y + index as u16, &truncated, style);
buf.set_string(inner.x, inner.y + i as u16, &truncated, style);
}
fn footer_height(&self) -> u16 {
if matches!(self.mode, AppMode::FormulaEdit { .. }) {
1
} else {
0
}
}
fn render_footer(&self, inner: Rect, buf: &mut Buffer) {
if matches!(self.mode, AppMode::FormulaEdit { .. }) {
let y = inner.y + inner.height.saturating_sub(1);
// Formula edit mode
if let AppMode::FormulaEdit { buffer } = self.mode {
let y = inner.y + inner.height.saturating_sub(2);
buf.set_string(
inner.x,
y,
"┄ Enter formula (Name = expr)",
"┄ Enter formula (Name = expr): ",
Style::default().fg(Color::Yellow),
);
let y = y + 1;
let prompt = format!("> {buffer}");
buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green));
}
}
}

View File

@ -6,51 +6,35 @@ use ratatui::{
};
use unicode_width::UnicodeWidthStr;
use crate::model::cell::CellValue;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::{AxisEntry, GridLayout};
/// Minimum column width — enough for short numbers/labels + 1 char gap.
const MIN_COL_WIDTH: u16 = 5;
const MAX_COL_WIDTH: u16 = 32;
const MIN_ROW_HEADER_W: u16 = 4;
const MAX_ROW_HEADER_W: u16 = 24;
/// Subtle dark-gray background used to highlight the row containing the cursor.
const ROW_HIGHLIGHT_BG: Color = Color::Indexed(237);
const ROW_HEADER_WIDTH: u16 = 16;
const COL_WIDTH: u16 = 10;
const GROUP_EXPANDED: &str = "";
const GROUP_COLLAPSED: &str = "";
pub struct GridWidget<'a> {
pub model: &'a Model,
pub layout: &'a GridLayout,
pub mode: &'a AppMode,
pub search_query: &'a str,
pub buffers: &'a std::collections::HashMap<String, String>,
pub drill_state: Option<&'a crate::ui::app::DrillState>,
}
impl<'a> GridWidget<'a> {
pub fn new(
model: &'a Model,
layout: &'a GridLayout,
mode: &'a AppMode,
search_query: &'a str,
buffers: &'a std::collections::HashMap<String, String>,
drill_state: Option<&'a crate::ui::app::DrillState>,
) -> Self {
pub fn new(model: &'a Model, mode: &'a AppMode, search_query: &'a str) -> Self {
Self {
model,
layout,
mode,
search_query,
buffers,
drill_state,
}
}
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
let view = self.model.active_view();
let layout = self.layout;
let layout = GridLayout::new(self.model, view);
let (sel_row, sel_col) = view.selected;
let row_offset = view.row_offset;
let col_offset = view.col_offset;
@ -59,9 +43,30 @@ impl<'a> GridWidget<'a> {
let n_col_levels = layout.col_cats.len().max(1);
let n_row_levels = layout.row_cats.len().max(1);
let col_widths = compute_col_widths(self.model, layout, fmt_comma, fmt_decimals);
// Sub-column widths for row header area
let sub_col_w = ROW_HEADER_WIDTH / n_row_levels as u16;
let sub_widths: Vec<u16> = (0..n_row_levels)
.map(|d| {
if d < n_row_levels - 1 {
sub_col_w
} else {
ROW_HEADER_WIDTH.saturating_sub(sub_col_w * (n_row_levels as u16 - 1))
}
})
.collect();
// ── Adaptive row header widths ───────────────────────────────
// Flat lists of data-only tuples for repeat-suppression in headers
let data_col_items: Vec<&Vec<String>> = layout
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
@ -74,63 +79,23 @@ impl<'a> GridWidget<'a> {
})
.collect();
let sub_widths: Vec<u16> = (0..n_row_levels)
.map(|d| {
let max_label = data_row_items
.iter()
.filter_map(|v| v.get(d))
.map(|s| s.width() as u16)
.max()
.unwrap_or(0);
(max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
})
.collect();
let row_header_width: u16 = sub_widths.iter().sum();
// Flat list of data-only column tuples for repeat-suppression in headers
let data_col_items: Vec<&Vec<String>> = layout
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
// Map each data-col index to its group name (None if ungrouped)
let col_groups: Vec<Option<String>> = {
let mut groups = Vec::new();
let mut current: Option<String> = None;
for entry in &layout.col_items {
match entry {
AxisEntry::GroupHeader { group_name, .. } => current = Some(group_name.clone()),
AxisEntry::DataItem(_) => groups.push(current.clone()),
}
})
.collect();
let has_col_groups = layout
.col_items
.iter()
.any(|e| matches!(e, AxisEntry::GroupHeader { .. }));
// Compute how many columns fit starting from col_offset.
let data_area_width = area.width.saturating_sub(row_header_width);
let mut acc = 0u16;
let mut last = col_offset;
for ci in col_offset..layout.col_count() {
let w = *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
if acc + w > data_area_width {
break;
}
acc += w;
last = ci + 1;
}
let visible_col_range = col_offset..last.max(col_offset + 1).min(layout.col_count());
// x offset (relative to the data area start) for each column index.
let col_x: Vec<u16> = {
let mut v = vec![0u16; layout.col_count() + 1];
for ci in 0..layout.col_count() {
v[ci + 1] = v[ci] + *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH);
}
v
groups
};
let col_x_at = |ci: usize| -> u16 {
area.x + row_header_width + col_x[ci].saturating_sub(col_x[col_offset])
};
let col_w_at = |ci: usize| -> u16 { *col_widths.get(ci).unwrap_or(&MIN_COL_WIDTH) };
let has_col_groups = col_groups.iter().any(|g| g.is_some());
let available_cols = ((area.width.saturating_sub(ROW_HEADER_WIDTH)) / COL_WIDTH) as usize;
let visible_col_range =
col_offset..(col_offset + available_cols.max(1)).min(layout.col_count());
let _header_rows = n_col_levels as u16 + 1 + if has_col_groups { 1 } else { 0 };
@ -148,44 +113,33 @@ impl<'a> GridWidget<'a> {
buf.set_string(
area.x,
y,
format!("{:<width$}", "", width = row_header_width as usize),
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut prev_group: Option<String> = None;
let mut x = area.x + ROW_HEADER_WIDTH;
let mut prev_group: Option<&str> = None;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let col_group = layout.col_group_for(ci);
let group_name = col_group.as_ref().map(|(_, g)| g.clone());
let label = if group_name != prev_group {
match &col_group {
Some((cat, g)) => {
let indicator = if view.is_group_collapsed(cat, g) {
GROUP_COLLAPSED
let group = col_groups[ci].as_deref();
let label = if group != prev_group {
group.unwrap_or("")
} else {
GROUP_EXPANDED
""
};
format!("{indicator} {g}")
}
None => String::new(),
}
} else {
String::new()
};
prev_group = group_name;
prev_group = group;
buf.set_string(
x,
y,
format!(
"{:<width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
truncate(label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
group_style,
);
x += COL_WIDTH;
}
y += 1;
}
@ -198,15 +152,11 @@ impl<'a> GridWidget<'a> {
buf.set_string(
area.x,
y,
format!("{:<width$}", "", width = row_header_width as usize),
format!("{:<width$}", "", width = ROW_HEADER_WIDTH as usize),
Style::default(),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let label = if layout.col_cats.is_empty() {
layout.col_label(ci)
} else {
@ -217,17 +167,7 @@ impl<'a> GridWidget<'a> {
String::new()
}
};
// Underline columns that share the same ancestor group as
// sel_col through level d. At the bottom level this matches
// only sel_col; at higher levels it spans all sub-columns.
let in_sel_group = if layout.col_cats.is_empty() {
ci == sel_col
} else if sel_col < data_col_items.len() && ci < data_col_items.len() {
data_col_items[ci][..=d] == data_col_items[sel_col][..=d]
} else {
false
};
let styled = if in_sel_group {
let styled = if ci == sel_col {
header_style.add_modifier(Modifier::UNDERLINED)
} else {
header_style
@ -237,11 +177,15 @@ impl<'a> GridWidget<'a> {
y,
format!(
"{:>width$}",
truncate(&label, cw.saturating_sub(1)),
width = cw
truncate(&label, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
styled,
);
x += COL_WIDTH;
if x >= area.x + area.width {
break;
}
}
y += 1;
}
@ -280,52 +224,34 @@ impl<'a> GridWidget<'a> {
y,
format!(
"{:<width$}",
truncate(&label, row_header_width as usize),
width = row_header_width as usize
truncate(&label, ROW_HEADER_WIDTH as usize),
width = ROW_HEADER_WIDTH as usize
),
group_header_style,
);
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let mut x = area.x + ROW_HEADER_WIDTH;
while x < area.x + area.width {
buf.set_string(
x,
y,
format!("{:─<width$}", "", width = cw),
format!("{:─<width$}", "", width = COL_WIDTH as usize),
Style::default().fg(Color::DarkGray),
);
x += COL_WIDTH;
}
}
AxisEntry::DataItem(_) => {
let ri = data_row_idx;
data_row_idx += 1;
let is_sel_row = ri == sel_row;
let row_style = if is_sel_row {
let row_style = if ri == sel_row {
Style::default()
.fg(Color::Cyan)
.bg(ROW_HIGHLIGHT_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
// Paint row-highlight background across the entire row
// (data area + any trailing space) so gaps between columns
// and the margin after the last column share the highlight.
if is_sel_row {
let row_w = (area.x + area.width).saturating_sub(area.x);
buf.set_string(
area.x + row_header_width,
y,
" ".repeat(row_w.saturating_sub(row_header_width) as usize),
Style::default().bg(ROW_HIGHLIGHT_BG),
);
}
// Multi-level row header — one sub-column per row category
let mut hx = area.x;
for d in 0..n_row_levels {
@ -350,87 +276,67 @@ impl<'a> GridWidget<'a> {
hx += sub_widths[d];
}
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range.clone() {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
// Check pending drill edits first, then use display_text
let cell_str = if let Some(ds) = self.drill_state {
let col_name = layout.col_label(ci);
ds.pending_edits
.get(&(ri, col_name))
.cloned()
.unwrap_or_else(|| {
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
})
} else {
layout.display_text(self.model, ri, ci, fmt_comma, fmt_decimals)
let key = match layout.cell_key(ri, ci) {
Some(k) => k,
None => {
x += COL_WIDTH;
continue;
}
};
let value = self.model.evaluate(&key);
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
let is_selected = ri == sel_row && ci == sel_col;
let is_search_match = !self.search_query.is_empty()
&& cell_str
.to_lowercase()
.contains(&self.search_query.to_lowercase());
// Aggregated cells (pivot view with hidden dims) are
// not directly editable — shown in italic to signal
// "drill to edit". Records mode cells are always
// directly editable, as are plain pivot cells.
let is_aggregated = !layout.is_records_mode()
&& layout.none_cats.iter().any(|c| {
self.model
.category(c)
.map(|cat| cat.kind.is_regular())
.unwrap_or(false)
});
let mut cell_style = if is_selected {
let cell_style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_search_match {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else if is_sel_row {
let fg = if cell_str.is_empty() {
Color::DarkGray
} else {
Color::White
};
Style::default().fg(fg).bg(ROW_HIGHLIGHT_BG)
} else if cell_str.is_empty() {
} else if value.is_none() {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
if is_aggregated {
cell_style = cell_style.add_modifier(Modifier::ITALIC);
}
buf.set_string(
x,
y,
format!(
"{:>width$}",
truncate(&cell_str, cw.saturating_sub(1)),
width = cw
truncate(&cell_str, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
cell_style,
);
x += COL_WIDTH;
}
// Edit indicator
if matches!(self.mode, AppMode::Editing { .. }) && ri == sel_row {
{
let buffer = self.buffers.get("edit").map(|s| s.as_str()).unwrap_or("");
let edit_x = col_x_at(sel_col);
let cw = col_w_at(sel_col) as usize;
if let AppMode::Editing { buffer } = self.mode {
let edit_x = area.x
+ ROW_HEADER_WIDTH
+ (sel_col.saturating_sub(col_offset)) as u16 * COL_WIDTH;
buf.set_string(
edit_x,
y,
truncate(&format!("{:<width$}", buffer, width = cw), cw),
truncate(
&format!("{:<width$}", buffer, width = COL_WIDTH as usize),
COL_WIDTH as usize,
),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::UNDERLINED),
@ -442,8 +348,8 @@ impl<'a> GridWidget<'a> {
y += 1;
}
// Total row — numeric aggregation, only meaningful in pivot mode.
if !layout.is_records_mode() && layout.row_count() > 0 && layout.col_count() > 0 {
// Total row
if layout.row_count() > 0 && layout.col_count() > 0 {
if y < area.y + area.height {
buf.set_string(
area.x,
@ -457,21 +363,20 @@ impl<'a> GridWidget<'a> {
buf.set_string(
area.x,
y,
format!("{:<width$}", "Total", width = row_header_width as usize),
format!("{:<width$}", "Total", width = ROW_HEADER_WIDTH as usize),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let mut x = area.x + ROW_HEADER_WIDTH;
for ci in visible_col_range {
let x = col_x_at(ci);
if x >= area.x + area.width {
break;
}
let cw = col_w_at(ci) as usize;
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
.map(|key| self.model.evaluate_f64(&key))
.sum();
let total_str = format_f64(total, fmt_comma, fmt_decimals);
buf.set_string(
@ -479,13 +384,14 @@ impl<'a> GridWidget<'a> {
y,
format!(
"{:>width$}",
truncate(&total_str, cw.saturating_sub(1)),
width = cw
truncate(&total_str, COL_WIDTH as usize),
width = COL_WIDTH as usize
),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
x += COL_WIDTH;
}
}
}
@ -502,9 +408,9 @@ impl<'a> Widget for GridWidget<'a> {
block.render(area, buf);
// Page axis bar
if !self.layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = self
.layout
let layout = GridLayout::new(self.model, self.model.active_view());
if !layout.page_coords.is_empty() && inner.height > 0 {
let page_info: Vec<String> = layout
.page_coords
.iter()
.map(|(cat, sel)| format!("{cat} = {sel}"))
@ -529,123 +435,53 @@ impl<'a> Widget for GridWidget<'a> {
}
}
/// Compute adaptive column widths for pivot mode (header labels + cell values).
/// Header widths use the widest *individual* level label (not the joined
/// multi-level string), matching how the grid renderer draws each level on
/// its own row with repeat-suppression.
pub fn compute_col_widths(
model: &Model,
layout: &GridLayout,
fmt_comma: bool,
fmt_decimals: u8,
) -> Vec<u16> {
let n = layout.col_count();
let mut widths = vec![0u16; n];
// Measure individual header level labels
let data_col_items: Vec<&Vec<String>> = layout
.col_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
match v {
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
Some(CellValue::Text(s)) => s.clone(),
None => String::new(),
}
})
.collect();
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
if let Some(levels) = data_col_items.get(ci) {
let max_level_w = levels.iter().map(|s| s.width() as u16).max().unwrap_or(0);
if max_level_w > *wref {
*wref = max_level_w;
}
}
}
// Measure cell content widths (works for both pivot and records modes)
for ri in 0..layout.row_count() {
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let s = layout.display_text(model, ri, ci, fmt_comma, fmt_decimals);
let w = s.width() as u16;
if w > *wref {
*wref = w;
}
}
}
// Measure total row (column sums) — pivot mode only
if !layout.is_records_mode() && layout.row_count() > 0 {
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
.sum();
let s = format_f64(total, fmt_comma, fmt_decimals);
let w = s.width() as u16;
if w > *wref {
*wref = w;
}
}
}
widths
.into_iter()
.map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
.collect()
}
/// Compute the total row header width from the layout's row items.
pub fn compute_row_header_width(layout: &GridLayout) -> u16 {
let n_row_levels = layout.row_cats.len().max(1);
let data_row_items: Vec<&Vec<String>> = layout
.row_items
.iter()
.filter_map(|e| {
if let AxisEntry::DataItem(v) = e {
Some(v)
} else {
None
}
})
.collect();
let sub_widths: Vec<u16> = (0..n_row_levels)
.map(|d| {
let max_label = data_row_items
.iter()
.filter_map(|v| v.get(d))
.map(|s| s.width() as u16)
.max()
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);
(max_label + 1).clamp(MIN_ROW_HEADER_W, MAX_ROW_HEADER_W)
})
.collect();
sub_widths.iter().sum()
(comma, decimals)
}
/// Count how many columns fit starting from `col_offset` given the available width.
pub fn compute_visible_cols(
col_widths: &[u16],
row_header_width: u16,
term_width: u16,
col_offset: usize,
) -> usize {
// Account for grid border (2 chars)
let data_area_width = term_width
.saturating_sub(2)
.saturating_sub(row_header_width);
let mut acc = 0u16;
let mut count = 0usize;
for w in &col_widths[col_offset..] {
let w = *w;
if acc + w > data_area_width {
break;
pub fn format_f64(n: f64, comma: bool, decimals: u8) -> String {
let formatted = format!("{:.prec$}", n, prec = decimals as usize);
if !comma {
return formatted;
}
acc += w;
count += 1;
// Split integer and decimal parts
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(',');
}
count.max(1)
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
}
// Re-export shared formatting functions
pub use crate::format::{format_f64, parse_number_format};
fn truncate(s: &str, max_width: usize) -> String {
let w = s.width();
if w <= max_width {
@ -677,7 +513,6 @@ mod tests {
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::GridLayout;
// ── Helpers ───────────────────────────────────────────────────────────────
@ -685,9 +520,7 @@ mod tests {
fn render(model: &Model, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
let bufs = std::collections::HashMap::new();
let layout = GridLayout::new(model, model.active_view());
GridWidget::new(model, &layout, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
GridWidget::new(model, &AppMode::Normal, "").render(area, &mut buf);
buf
}
@ -717,7 +550,6 @@ mod tests {
}
/// Minimal model: Type on Row, Month on Column.
/// Every cell has a value so rows/cols survive pruning.
fn two_cat_model() -> Model {
let mut m = Model::new("Test");
m.add_category("Type").unwrap(); // → Row
@ -730,12 +562,6 @@ mod tests {
c.add_item("Jan");
c.add_item("Feb");
}
// Fill every cell so nothing is pruned as empty.
for t in ["Food", "Clothing"] {
for mo in ["Jan", "Feb"] {
m.set_cell(coord(&[("Type", t), ("Month", mo)]), CellValue::Number(1.0));
}
}
m
}
@ -795,19 +621,10 @@ mod tests {
#[test]
fn unset_cells_show_no_value() {
// Build a model without the two_cat_model helper (which fills every cell).
let mut m = Model::new("Test");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
// Set one cell so the row/col isn't pruned
m.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan")]),
CellValue::Number(1.0),
);
let m = two_cat_model();
let text = buf_text(&render(&m, 80, 24));
// Should not contain large numbers that weren't set
// No digits should appear in the data area if nothing is set
// (Total row shows "0" — exclude that from this check by looking for non-zero)
assert!(!text.contains("100"), "unexpected '100' in:\n{text}");
}
@ -892,12 +709,11 @@ mod tests {
// ── Formula evaluation ────────────────────────────────────────────────────
#[test]
#[ignore = "needs render harness update for _Measure virtual category"]
fn formula_cell_renders_computed_value() {
let mut m = Model::new("Test");
m.add_category("_Measure").unwrap(); // → Row
m.add_category("Measure").unwrap(); // → Row
m.add_category("Region").unwrap(); // → Column
if let Some(c) = m.category_mut("_Measure") {
if let Some(c) = m.category_mut("Measure") {
c.add_item("Revenue");
c.add_item("Cost");
c.add_item("Profit");
@ -906,16 +722,14 @@ mod tests {
c.add_item("East");
}
m.set_cell(
coord(&[("_Measure", "Revenue"), ("Region", "East")]),
coord(&[("Measure", "Revenue"), ("Region", "East")]),
CellValue::Number(1000.0),
);
m.set_cell(
coord(&[("_Measure", "Cost"), ("Region", "East")]),
coord(&[("Measure", "Cost"), ("Region", "East")]),
CellValue::Number(600.0),
);
m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap());
m.active_view_mut().set_axis("_Measure", crate::view::Axis::Row);
m.active_view_mut().set_axis("Region", crate::view::Axis::Column);
m.add_formula(parse_formula("Profit = Revenue - Cost", "Measure").unwrap());
let text = buf_text(&render(&m, 80, 24));
assert!(text.contains("400"), "expected '400' (Profit) in:\n{text}");
@ -942,15 +756,6 @@ mod tests {
}
m.active_view_mut()
.set_axis("Recipient", crate::view::Axis::Row);
// Populate cells so rows/cols survive pruning
for t in ["Food", "Clothing"] {
for r in ["Alice", "Bob"] {
m.set_cell(
coord(&[("Type", t), ("Month", "Jan"), ("Recipient", r)]),
CellValue::Number(1.0),
);
}
}
let text = buf_text(&render(&m, 80, 24));
// Multi-level row headers: category values shown separately, not joined with /
@ -1014,13 +819,6 @@ mod tests {
}
m.active_view_mut()
.set_axis("Year", crate::view::Axis::Column);
// Populate cells so cols survive pruning
for y in ["2024", "2025"] {
m.set_cell(
coord(&[("Type", "Food"), ("Month", "Jan"), ("Year", y)]),
CellValue::Number(1.0),
);
}
let text = buf_text(&render(&m, 80, 24));
// Multi-level column headers: category values shown separately, not joined with /

View File

@ -5,599 +5,121 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget},
};
/// Number of help pages available.
pub const HELP_PAGE_COUNT: usize = 5;
/// Style presets used throughout help pages.
struct HelpStyles {
heading: Style,
key: Style,
dim: Style,
normal: Style,
accent: Style,
banner: Style,
}
impl HelpStyles {
fn new() -> Self {
Self {
heading: Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
key: Style::default().fg(Color::Cyan),
dim: Style::default().fg(Color::DarkGray),
normal: Style::default(),
accent: Style::default().fg(Color::Green),
banner: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
}
}
}
/// A styled line in a help page: two columns (key + description) plus a style.
/// When `desc` is empty the `key` text spans the full width.
struct HelpLine {
key: &'static str,
desc: &'static str,
style: Style,
}
impl HelpLine {
fn heading(text: &'static str, styles: &HelpStyles) -> Self {
Self {
key: text,
desc: "",
style: styles.heading,
}
}
fn key(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self {
Self {
key: k,
desc: d,
style: styles.key,
}
}
fn dim(k: &'static str, d: &'static str, styles: &HelpStyles) -> Self {
Self {
key: k,
desc: d,
style: styles.dim,
}
}
fn text(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.normal,
}
}
fn accent(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.accent,
}
}
fn banner(t: &'static str, styles: &HelpStyles) -> Self {
Self {
key: t,
desc: "",
style: styles.banner,
}
}
fn blank() -> Self {
Self {
key: "",
desc: "",
style: Style::default(),
}
}
}
/// The page titles shown in the tab bar.
const PAGE_TITLES: [&str; HELP_PAGE_COUNT] = [
"Welcome",
"Navigation",
"Editing",
"Panels & Views",
"Commands",
];
// ── Page content builders ───────────────────────────────────────────────────
fn page_welcome(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::banner(" improvise", s),
HelpLine::text(
" A multi-dimensional data modeling tool in your terminal.",
s,
),
HelpLine::blank(),
HelpLine::text(
" improvise lets you build spreadsheet-like models organized",
s,
),
HelpLine::text(
" by categories (dimensions). Each category has items, and",
s,
),
HelpLine::text(
" every combination of items across categories forms a cell.",
s,
),
HelpLine::text(
" Think of it like a pivot table you can build from scratch.",
s,
),
HelpLine::blank(),
HelpLine::heading("Quick start", s),
HelpLine::blank(),
HelpLine::text(" 1. Create categories (dimensions) for your model:", s),
HelpLine::accent(" :add-cat Region :add-cat Product", s),
HelpLine::blank(),
HelpLine::text(" 2. Add items to each category:", s),
HelpLine::accent(" :add-items Region North South East West", s),
HelpLine::accent(" :add-items Product Widget Gadget", s),
HelpLine::blank(),
HelpLine::text(
" 3. Navigate with hjkl or arrow keys and press i to edit cells.",
s,
),
HelpLine::blank(),
HelpLine::text(" 4. Add formulas to compute values automatically:", s),
HelpLine::accent(" :formula Product Total = Widget + Gadget", s),
HelpLine::blank(),
HelpLine::text(" 5. Save your work:", s),
HelpLine::accent(" :w mymodel.improv", s),
HelpLine::blank(),
HelpLine::heading("Core concepts", s),
HelpLine::blank(),
HelpLine::text(
" Category A dimension of your data (e.g. Region, Time, Product).",
s,
),
HelpLine::text(
" Item A member of a category (e.g. North, Q1, Widget).",
s,
),
HelpLine::text(
" View A saved layout: which categories go on rows, columns, or pages.",
s,
),
HelpLine::text(
" Tile The row/column/page assignment of a category.",
s,
),
HelpLine::text(
" Formula A computed item: derives its value from other items.",
s,
),
HelpLine::blank(),
HelpLine::dim(" Tip: press Tab or l/n to go to the next page.", "", s),
]
}
fn page_navigation(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Cursor movement", s),
HelpLine::blank(),
HelpLine::key(" hjkl / Arrow keys", "Move one cell", s),
HelpLine::key(" gg", "Jump to first row", s),
HelpLine::key(" G", "Jump to last row", s),
HelpLine::key(" 0 / Home", "Jump to first column", s),
HelpLine::key(" $ / End", "Jump to last column", s),
HelpLine::blank(),
HelpLine::heading("Scrolling", s),
HelpLine::blank(),
HelpLine::key(" Ctrl+D", "Scroll half-page down", s),
HelpLine::key(" Ctrl+U", "Scroll half-page up", s),
HelpLine::key(" PageDown", "Scroll three-quarters page down", s),
HelpLine::key(" PageUp", "Scroll three-quarters page up", s),
HelpLine::blank(),
HelpLine::heading("Page-axis cycling", s),
HelpLine::blank(),
HelpLine::text(" When a category is on the Page axis, only one item is", s),
HelpLine::text(
" visible at a time. Use [ and ] to cycle through them.",
s,
),
HelpLine::blank(),
HelpLine::key(" [", "Previous page-axis item", s),
HelpLine::key(" ]", "Next page-axis item", s),
HelpLine::blank(),
HelpLine::heading("Search", s),
HelpLine::blank(),
HelpLine::key(
" /",
"Start search — type a pattern, matching cells highlight",
s,
),
HelpLine::key(" n", "Jump to next match", s),
HelpLine::key(" N", "Jump to previous match", s),
HelpLine::key(" Esc or Enter", "Exit search mode", s),
HelpLine::blank(),
HelpLine::heading("View history", s),
HelpLine::blank(),
HelpLine::key(" >", "Drill into selected cell (record view)", s),
HelpLine::key(" <", "Go back to previous view", s),
]
}
fn page_editing(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Entering edit mode", s),
HelpLine::blank(),
HelpLine::key(" i / a", "Edit current cell (insert mode)", s),
HelpLine::key(" Enter", "Edit current cell (same as i)", s),
HelpLine::key(" Esc", "Cancel edit, return to Normal mode", s),
HelpLine::blank(),
HelpLine::heading("While editing", s),
HelpLine::blank(),
HelpLine::text(" Type normally to enter a value. Values can be:", s),
HelpLine::accent(" Numbers: 42 3.14 -100", s),
HelpLine::accent(" Text: hello world", s),
HelpLine::blank(),
HelpLine::key(" Enter", "Commit value and move down", s),
HelpLine::key(
" Tab",
"Commit value and move right (stay in edit mode)",
s,
),
HelpLine::key(" Esc", "Discard edits and return to Normal", s),
HelpLine::blank(),
HelpLine::heading("Copy and paste", s),
HelpLine::blank(),
HelpLine::key(" yy", "Yank (copy) the current cell value", s),
HelpLine::key(" p", "Paste the yanked value into the current cell", s),
HelpLine::blank(),
HelpLine::heading("Cell operations", s),
HelpLine::blank(),
HelpLine::key(" x", "Clear the current cell", s),
HelpLine::blank(),
HelpLine::heading("Formulas", s),
HelpLine::blank(),
HelpLine::text(
" Formulas are computed items. A formula belongs to a category",
s,
),
HelpLine::text(
" and derives its value from other items in that category.",
s,
),
HelpLine::blank(),
HelpLine::text(
" Example: in a Product category with items Widget and Gadget:",
s,
),
HelpLine::accent(" :formula Product Total = Widget + Gadget", s),
HelpLine::blank(),
HelpLine::text(" Supported operators: + - * /", s),
HelpLine::text(
" Formula cells update automatically when source values change.",
s,
),
]
}
fn page_panels(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Side panels", s),
HelpLine::blank(),
HelpLine::text(
" Panels open on the right side of the screen and give you",
s,
),
HelpLine::text(" quick access to formulas, categories, and views.", s),
HelpLine::blank(),
HelpLine::key(" F", "Toggle Formula panel", s),
HelpLine::dim(" n", "New formula", s),
HelpLine::dim(" d", "Delete selected formula", s),
HelpLine::blank(),
HelpLine::key(" C", "Toggle Category panel", s),
HelpLine::dim(" n", "New category", s),
HelpLine::dim(" a", "Add items to selected category", s),
HelpLine::dim(" d", "Delete selected category/item", s),
HelpLine::blank(),
HelpLine::key(" N", "Quick-add a new category (from anywhere)", s),
HelpLine::blank(),
HelpLine::key(" V", "Toggle View panel", s),
HelpLine::dim(" n", "New view", s),
HelpLine::dim(" d", "Delete selected view", s),
HelpLine::dim(" Enter", "Switch to selected view", s),
HelpLine::blank(),
HelpLine::key(" Tab", "Cycle focus between open panels", s),
HelpLine::blank(),
HelpLine::heading("Tile select mode (T)", s),
HelpLine::blank(),
HelpLine::text(" Tiles control which axis each category is placed on.", s),
HelpLine::text(" Press T to enter tile-select mode, then:", s),
HelpLine::blank(),
HelpLine::key(" h / l (← →)", "Select previous / next category tile", s),
HelpLine::key(" Space / Enter", "Cycle axis: Row → Col → Page", s),
HelpLine::key(" r", "Set axis to Row", s),
HelpLine::key(" c", "Set axis to Col", s),
HelpLine::key(" p", "Set axis to Page", s),
HelpLine::key(" Esc", "Exit tile-select mode", s),
HelpLine::blank(),
HelpLine::heading("Groups and visibility", s),
HelpLine::blank(),
HelpLine::key(" z", "Toggle collapse of nearest group above cursor", s),
HelpLine::key(" H", "Hide current row item", s),
HelpLine::dim(" :show-item <cat> <item>", "Restore a hidden item", s),
]
}
fn page_commands(s: &HelpStyles) -> Vec<HelpLine> {
vec![
HelpLine::blank(),
HelpLine::heading("Command line ( : )", s),
HelpLine::blank(),
HelpLine::text(
" Press : to open the command line. Commands are entered",
s,
),
HelpLine::text(" vim-style and executed with Enter. Esc cancels.", s),
HelpLine::blank(),
HelpLine::heading("File operations", s),
HelpLine::blank(),
HelpLine::key(" :w [path]", "Save (path optional after first save)", s),
HelpLine::key(" :wq", "Save and quit", s),
HelpLine::key(" :q", "Quit (warns if unsaved changes)", s),
HelpLine::key(" :q!", "Force quit without saving", s),
HelpLine::key(" ZZ", "Save and quit (same as :wq)", s),
HelpLine::key(" Ctrl+S", "Save (same as :w)", s),
HelpLine::blank(),
HelpLine::heading("Import and export", s),
HelpLine::blank(),
HelpLine::key(" :import <path>", "Open the JSON/CSV import wizard", s),
HelpLine::key(" :export [path.csv]", "Export the active view to CSV", s),
HelpLine::blank(),
HelpLine::heading("Model building", s),
HelpLine::blank(),
HelpLine::key(" :add-cat <name>", "Add a new category", s),
HelpLine::key(" :add-item <cat> <item>", "Add one item to a category", s),
HelpLine::key(
" :add-items <cat> a b c ...",
"Add multiple items at once",
s,
),
HelpLine::key(" :formula <cat> <Name=expr>", "Add a formula", s),
HelpLine::blank(),
HelpLine::heading("Views", s),
HelpLine::blank(),
HelpLine::key(" :add-view [name]", "Create a new view", s),
HelpLine::blank(),
HelpLine::heading("Display", s),
HelpLine::blank(),
HelpLine::key(" :set-format <fmt>", "Set number format (e.g. ',.2')", s),
HelpLine::blank(),
HelpLine::heading("Other keys", s),
HelpLine::blank(),
HelpLine::key(" ? or F1", "Open this help screen", s),
HelpLine::key(" :help", "Open this help screen", s),
]
}
/// Builds the content lines for a given page index.
fn page_content(page: usize) -> Vec<HelpLine> {
let styles = HelpStyles::new();
match page {
0 => page_welcome(&styles),
1 => page_navigation(&styles),
2 => page_editing(&styles),
3 => page_panels(&styles),
4 => page_commands(&styles),
_ => page_welcome(&styles),
}
}
// ── Widget ──────────────────────────────────────────────────────────────────
pub struct HelpWidget {
page: usize,
}
impl HelpWidget {
pub fn new(page: usize) -> Self {
// Clamp to valid range
let page = page.min(HELP_PAGE_COUNT - 1);
Self { page }
}
}
pub struct HelpWidget;
impl Widget for HelpWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
// Use most of the screen, leaving a small margin
let margin_x = if area.width > 90 { 4 } else { 1 };
let margin_y = if area.height > 30 { 2 } else { 1 };
let popup_w = area.width.saturating_sub(margin_x * 2);
let popup_h = area.height.saturating_sub(margin_y * 2);
let x = area.x + margin_x;
let y = area.y + margin_y;
let popup_w = 66u16.min(area.width);
let popup_h = 36u16.min(area.height);
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(" improvise — help ")
.title(" improvise — key reference (any key to close) ")
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
if inner.height < 4 || inner.width < 20 {
return;
}
// ── Tab bar ─────────────────────────────────────────────────────
let tab_y = inner.y;
render_tab_bar(buf, inner.x, tab_y, inner.width, self.page);
// ── Separator line ──────────────────────────────────────────────
let sep_y = tab_y + 1;
let sep_line: String = "".repeat(inner.width as usize);
buf.set_string(
inner.x,
sep_y,
&sep_line,
Style::default().fg(Color::DarkGray),
);
// ── Page content ────────────────────────────────────────────────
let content_start_y = sep_y + 1;
let content_height = inner.height.saturating_sub(4); // tab + sep + footer_sep + footer
let lines = page_content(self.page);
let key_col_width = 32usize;
let normal_style = Style::default();
for (i, line) in lines.iter().enumerate() {
if i >= content_height as usize {
break;
}
let ly = content_start_y + i as u16;
if line.desc.is_empty() {
// Single-column line (headings, text, blanks)
buf.set_string(inner.x, ly, line.key, line.style);
} else {
// Two-column line: key on the left, description on the right
buf.set_string(inner.x, ly, line.key, line.style);
let desc_x = inner.x + key_col_width as u16;
if desc_x < inner.x + inner.width {
buf.set_string(desc_x, ly, line.desc, normal_style);
}
}
}
// ── Footer separator ────────────────────────────────────────────
let footer_sep_y = inner.y + inner.height - 2;
let footer_sep: String = "".repeat(inner.width as usize);
buf.set_string(
inner.x,
footer_sep_y,
&footer_sep,
Style::default().fg(Color::DarkGray),
);
// ── Footer ─────────────────────────────────────────────────────
let footer_y = inner.y + inner.height - 1;
render_footer(buf, inner.x, footer_y, inner.width, self.page);
}
}
/// Renders the tab bar showing all page titles with the active page highlighted.
fn render_tab_bar(buf: &mut Buffer, x: u16, y: u16, width: u16, active_page: usize) {
let inactive_style = Style::default().fg(Color::DarkGray);
let active_style = Style::default()
.fg(Color::White)
.bg(Color::Blue)
let head = Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD);
let separator_style = Style::default().fg(Color::DarkGray);
let mut col = x;
let max_col = x + width;
for (i, title) in PAGE_TITLES.iter().enumerate() {
if col >= max_col {
break;
}
// Separator between tabs
if i > 0 {
if col + 3 >= max_col {
break;
}
buf.set_string(col, y, "", separator_style);
col += 3;
}
let style = if i == active_page {
active_style
} else {
inactive_style
};
let label = format!(" {} ", title);
let label_width = label.len() as u16;
if col + label_width > max_col {
break;
}
buf.set_string(col, y, &label, style);
col += label_width;
}
}
/// Renders the footer with page navigation hints.
fn render_footer(buf: &mut Buffer, x: u16, y: u16, width: u16, page: usize) {
let key = Style::default().fg(Color::Cyan);
let dim = Style::default().fg(Color::DarkGray);
let key_style = Style::default().fg(Color::Cyan);
let norm = Style::default();
let page_indicator = format!(" page {} of {} ", page + 1, HELP_PAGE_COUNT);
let nav_parts: Vec<(&str, Style)> = if page == 0 && HELP_PAGE_COUNT > 1 {
vec![
(" ", dim),
("l", key_style),
("/", dim),
("Tab", key_style),
(": next", dim),
]
} else if page >= HELP_PAGE_COUNT - 1 {
vec![
(" ", dim),
("h", key_style),
("/", dim),
("S-Tab", key_style),
(": prev", dim),
]
} else {
vec![
(" ", dim),
("h", key_style),
(": prev ", dim),
("l", key_style),
(": next", dim),
]
};
let close_parts: Vec<(&str, Style)> = vec![
(" ", dim),
("q", key_style),
("/", dim),
("Esc", key_style),
(": close", dim),
// (key_col, desc_col, style)
let rows: &[(&str, &str, Style)] = &[
("Navigation", "", head),
(" hjkl / ↑↓←→", "Move cursor", key),
(" gg / G", "First / last row", key),
(" 0 / $", "First / last column", key),
(" Ctrl+D / Ctrl+U", "Scroll ½-page down / up", key),
(" [ / ]", "Cycle page-axis filter", key),
("", "", norm),
("Editing", "", head),
(" i / a / Enter", "Enter Insert mode", key),
(" Esc", "Return to Normal mode", key),
(" x", "Clear cell", key),
(" yy", "Yank (copy) cell value", key),
(" p", "Paste yanked value", key),
("", "", norm),
("Search", "", head),
(" /", "Enter search, highlight matches", key),
(" n / N", "Next / previous match", key),
(" Esc or Enter", "Exit search", key),
("", "", norm),
("Panels", "", head),
(" F", "Toggle Formula panel (n:new d:del)", key),
(
" C",
"Toggle Category panel (n:new-cat a:add-items)",
key,
),
(" N", "New category quick-add (from anywhere)", key),
(
" V",
"Toggle View panel (n:new d:del Enter:switch)",
key,
),
(" Tab", "Focus next open panel", key),
("", "", norm),
("Pivot / Tiles / Groups", "", head),
(" z", "Toggle collapse nearest group above cursor", key),
(
" H",
"Hide current row item (:show-item cat item to restore)",
key,
),
(" T", "Tile-select mode", key),
(" ← h / → l", "Select previous/next tile", dim),
(" Space / Enter", "Cycle axis (Row→Col→Page)", dim),
(" r / c / p", "Set axis to Row / Col / Page", dim),
("", "", norm),
("Command line ( : )", "", head),
(
" :q :q! :wq ZZ",
"Quit / force-quit / save+quit",
key,
),
(" :w [path]", "Save (path optional)", key),
(" :import <path.json>", "Open JSON import wizard", key),
(" :export [path.csv]", "Export active view to CSV", key),
(" :add-cat <name>", "Add a category", key),
(
" :add-item <cat> <item>",
"Add one item to a category",
key,
),
(
" :add-items <cat> a b c…",
"Add multiple items at once",
key,
),
(" :formula <cat> <Name=expr>", "Add a formula", key),
(" :add-view [name]", "Create a new view", key),
("", "", norm),
(" ? or F1", "This help", key),
(" Ctrl+S", "Save (same as :w)", key),
];
// Render page indicator on the left
buf.set_string(x, y, &page_indicator, dim);
// Render navigation hints after the page indicator
let mut col = x + page_indicator.len() as u16;
for (text, style) in &nav_parts {
if col >= x + width {
let key_col_w = 32usize;
for (i, (k, d, style)) in rows.iter().enumerate() {
if i >= inner.height as usize {
break;
}
buf.set_string(col, y, text, *style);
col += text.len() as u16;
let y = inner.y + i as u16;
if d.is_empty() {
buf.set_string(inner.x, y, k, *style);
} else {
buf.set_string(inner.x, y, k, *style);
let dx = inner.x + key_col_w as u16;
if dx < inner.x + inner.width {
buf.set_string(dx, y, d, norm);
}
}
// Render close hint
for (text, style) in &close_parts {
if col >= x + width {
break;
}
buf.set_string(col, y, text, *style);
col += text.len() as u16;
}
}

View File

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

View File

@ -1,12 +1,8 @@
pub mod app;
pub mod cat_tree;
pub mod category_panel;
pub mod effect;
pub mod formula_panel;
pub mod grid;
pub mod help;
pub mod import_wizard_ui;
pub mod panel;
pub mod tile_bar;
pub mod view_panel;
pub mod which_key;

View File

@ -1,87 +0,0 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Widget},
};
use crate::ui::app::AppMode;
/// Trait for panel-specific content. Implement this to create a new side panel.
pub trait PanelContent {
/// Whether the panel should appear active given the current mode.
fn is_active(&self, mode: &AppMode) -> bool;
/// Color used for the active border AND the selection highlight background.
fn active_color(&self) -> Color;
/// Block title string (include surrounding spaces for padding).
fn title(&self) -> &str;
/// Number of renderable rows.
fn item_count(&self) -> usize;
/// Message shown when `item_count()` returns 0.
fn empty_message(&self) -> &str;
/// Render a single item at the given row index.
/// `inner` is the full inner area of the panel; the item occupies row `index`.
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer);
/// Number of lines the footer occupies (used to reserve space).
fn footer_height(&self) -> u16 {
0
}
/// Optional footer rendered in the reserved space at the bottom.
fn render_footer(&self, _inner: Rect, _buf: &mut Buffer) {}
}
/// Generic side-panel widget that delegates content rendering to a `PanelContent` impl.
pub struct Panel<'a, C: PanelContent> {
content: C,
mode: &'a AppMode,
cursor: usize,
}
impl<'a, C: PanelContent> Panel<'a, C> {
pub fn new(content: C, mode: &'a AppMode, cursor: usize) -> Self {
Self {
content,
mode,
cursor,
}
}
}
impl<C: PanelContent> Widget for Panel<'_, C> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = self.content.is_active(self.mode);
let border_style = if is_active {
Style::default().fg(self.content.active_color())
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(self.content.title());
let inner = block.inner(area);
block.render(area, buf);
if self.content.item_count() == 0 {
buf.set_string(
inner.x,
inner.y,
self.content.empty_message(),
Style::default().fg(Color::DarkGray),
);
return;
}
let item_height = inner.height.saturating_sub(self.content.footer_height());
for i in 0..self.content.item_count() {
if i as u16 >= item_height {
break;
}
let is_selected = i == self.cursor && is_active;
self.content.render_item(i, is_selected, inner, buf);
}
self.content.render_footer(inner, buf);
}
}

View File

@ -4,119 +4,50 @@ use ratatui::{
style::{Color, Modifier, Style},
widgets::Widget,
};
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("", Color::Green),
Axis::Column => ("", Color::Blue),
Axis::Page => ("", Color::Magenta),
}
}
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub tile_cat_idx: usize,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
Self {
model,
mode,
tile_cat_idx,
}
}
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("Row", Color::Green),
Axis::Column => ("Col", Color::Blue),
Axis::Page => ("Pag", Color::Magenta),
Axis::None => ("·", Color::DarkGray),
}
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
Self { model, mode }
}
}
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
// Clear the line to avoid stale characters from previous renders
buf.set_string(
area.x,
area.y,
" ".repeat(area.width as usize),
Style::default(),
);
let view = self.model.active_view();
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
Some(self.tile_cat_idx)
let selected_cat_idx = if let AppMode::TileSelect { cat_idx } = self.mode {
Some(*cat_idx)
} else {
None
};
let prefix = " Tiles: ";
let prefix_w = prefix.width() as u16;
buf.set_string(area.x, area.y, prefix, Style::default().fg(Color::Gray));
let mut x = area.x + 1;
buf.set_string(area.x, area.y, " Tiles: ", Style::default().fg(Color::Gray));
x += 8;
let cat_names: Vec<&str> = self.model.category_names();
// Compute label widths for all tiles
let labels: Vec<String> = cat_names
.iter()
.map(|cat_name| {
let (axis_symbol, _) = TileBar::axis_display(view.axis_of(cat_name));
format!(" [{cat_name} {axis_symbol}] ")
})
.collect();
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
// Available space for tiles (after prefix)
let avail = area.width.saturating_sub(prefix_w);
// Find the minimal starting index so the selected tile is fully visible.
// We scroll by whole tiles: find the first tile to draw such that the
// selected tile fits within the available width.
let sel = selected_cat_idx.unwrap_or(0);
let mut start = 0;
loop {
// Check if selected tile is visible when starting from `start`
let mut used: u16 = 0;
let mut sel_visible = false;
for i in start..labels.len() {
if used + widths[i] > avail {
break;
}
used += widths[i];
if i == sel {
sel_visible = true;
}
}
if sel_visible || start >= sel {
break;
}
start += 1;
}
// Draw an overflow indicator if we scrolled past the beginning
let mut x = area.x + prefix_w + 1;
if start > 0 {
buf.set_string(
area.x + prefix_w,
area.y,
"",
Style::default().fg(Color::DarkGray),
);
x += 1;
}
// Render tiles from `start`
let mut last_drawn = start;
for i in start..labels.len() {
let label_w = widths[i];
if x + label_w > area.x + area.width {
break;
}
let (_, axis_color) = TileBar::axis_display(view.axis_of(cat_names[i]));
for (i, cat_name) in cat_names.iter().enumerate() {
let (axis_symbol, axis_color) = axis_display(view.axis_of(cat_name));
let label = format!(" [{cat_name} {axis_symbol}] ");
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default()
.fg(Color::Black)
@ -126,26 +57,22 @@ impl<'a> Widget for TileBar<'a> {
Style::default().fg(axis_color)
};
buf.set_string(x, area.y, &labels[i], style);
x += label_w;
last_drawn = i;
if x + label.len() as u16 > area.x + area.width {
break;
}
// Draw overflow indicator if tiles remain after the visible area
if last_drawn + 1 < labels.len() && x < area.x + area.width {
buf.set_string(x, area.y, "", Style::default().fg(Color::DarkGray));
x += 1;
buf.set_string(x, area.y, &label, style);
x += label.len() as u16;
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
if matches!(self.mode, AppMode::TileSelect { .. }) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.width() as u16 <= area.x + area.width {
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.width() as u16 <= area.x + area.width {
if x + hint.len() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}

View File

@ -2,78 +2,54 @@ use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::ui::panel::PanelContent;
use crate::view::Axis;
pub struct ViewContent<'a> {
view_names: Vec<String>,
active_view: String,
model: &'a Model,
pub struct ViewPanel<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub cursor: usize,
}
impl<'a> ViewContent<'a> {
pub fn new(model: &'a Model) -> Self {
let view_names: Vec<String> = model.views.keys().cloned().collect();
let active_view = model.active_view.clone();
impl<'a> ViewPanel<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
Self {
view_names,
active_view,
model,
mode,
cursor,
}
}
/// Build a short axis summary for a view, e.g. "R:Region C:Product P:Time"
fn axis_summary(&self, view_name: &str) -> String {
let Some(view) = self.model.views.get(view_name) else {
return String::new();
};
let mut parts = Vec::new();
for axis in [Axis::Row, Axis::Column, Axis::Page] {
let cats = view.categories_on(axis);
// Filter out virtual categories
let cats: Vec<&str> = cats.into_iter().filter(|c| !c.starts_with('_')).collect();
if !cats.is_empty() {
let prefix = match axis {
Axis::Row => "R",
Axis::Column => "C",
Axis::Page => "P",
Axis::None => "",
};
parts.push(format!("{}:{}", prefix, cats.join(",")));
}
}
parts.join(" ")
}
}
impl PanelContent for ViewContent<'_> {
fn is_active(&self, mode: &AppMode) -> bool {
matches!(mode, AppMode::ViewPanel)
impl<'a> Widget for ViewPanel<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let is_active = matches!(self.mode, AppMode::ViewPanel);
let border_style = if is_active {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::DarkGray)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Views [Enter] switch [n]ew [d]elete ");
let inner = block.inner(area);
block.render(area, buf);
let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect();
let active = &self.model.active_view;
for (i, view_name) in view_names.iter().enumerate() {
if inner.y + i as u16 >= inner.y + inner.height {
break;
}
fn active_color(&self) -> Color {
Color::Blue
}
fn title(&self) -> &str {
" Views "
}
fn item_count(&self) -> usize {
self.view_names.len()
}
fn empty_message(&self) -> &str {
"(no views)"
}
fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) {
let view_name = &self.view_names[index];
let is_active_view = view_name == &self.active_view;
let is_selected = i == self.cursor && is_active;
let is_active_view = *view_name == active.as_str();
let style = if is_selected {
Style::default()
@ -89,22 +65,12 @@ impl PanelContent for ViewContent<'_> {
};
let prefix = if is_active_view { "" } else { " " };
let name_text = format!("{prefix}{view_name}");
let y = inner.y + index as u16;
buf.set_string(inner.x, y, &name_text, style);
// Axis summary after the name, in dim text
let summary = self.axis_summary(view_name);
if !summary.is_empty() {
let summary_x = inner.x + name_text.len() as u16 + 1;
if summary_x < inner.x + inner.width {
let summary_style = if is_selected {
style
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(summary_x, y, &summary, summary_style);
}
buf.set_string(
inner.x,
inner.y + i as u16,
format!("{prefix}{view_name}"),
style,
);
}
}
}

View File

@ -1,67 +0,0 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
/// A compact popup showing available key completions after a prefix key,
/// Emacs which-key style.
pub struct WhichKeyWidget<'a> {
hints: &'a [(String, &'static str)],
}
impl<'a> WhichKeyWidget<'a> {
pub fn new(hints: &'a [(String, &'static str)]) -> Self {
Self { hints }
}
}
impl Widget for WhichKeyWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.hints.is_empty() {
return;
}
// Size: width fits the longest "key command" line, height = hint count + border
let content_width = self
.hints
.iter()
.map(|(k, cmd)| k.len() + 2 + cmd.len())
.max()
.unwrap_or(10);
let popup_w = (content_width as u16 + 4).min(area.width); // +4 for border + padding
let popup_h = (self.hints.len() as u16 + 2).min(area.height); // +2 for border
// Position: bottom-center, above the status bar
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h + 2); // 2 lines above bottom
let popup_area = Rect::new(x, y, popup_w, popup_h);
Clear.render(popup_area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(" which-key ");
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let cmd_style = Style::default().fg(Color::Gray);
for (i, (key_label, cmd_name)) in self.hints.iter().enumerate() {
if i >= inner.height as usize {
break;
}
let y = inner.y + i as u16;
buf.set_string(inner.x + 1, y, key_label, key_style);
let cmd_x = inner.x + 4; // fixed column for command names
if cmd_x < inner.x + inner.width {
buf.set_string(cmd_x, y, cmd_name, cmd_style);
}
}
}
}

View File

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

View File

@ -1,17 +1,7 @@
use std::rc::Rc;
use crate::model::cell::{CellKey, CellValue};
use crate::model::cell::CellKey;
use crate::model::Model;
use crate::view::{Axis, View};
/// Extract (record_index, dim_name) from a synthetic records-mode CellKey.
/// Returns None for normal pivot-mode keys.
pub fn synthetic_record_info(key: &CellKey) -> Option<(usize, String)> {
let idx: usize = key.get("_Index")?.parse().ok()?;
let dim = key.get("_Dim")?.to_string();
Some((idx, dim))
}
/// One entry on a grid axis: either a visual group header or a data-item tuple.
///
/// `GroupHeader` entries are always visible so the user can see the group label
@ -37,37 +27,9 @@ pub struct GridLayout {
pub page_coords: Vec<(String, String)>,
pub row_items: Vec<AxisEntry>,
pub col_items: Vec<AxisEntry>,
/// Categories on `Axis::None` — hidden, implicitly aggregated.
pub none_cats: Vec<String>,
/// In records mode: the filtered cell list, one per row.
/// None for normal pivot views. Rc for cheap sharing.
pub records: Option<Rc<Vec<(CellKey, CellValue)>>>,
}
impl GridLayout {
/// Build a layout. When records-mode is active and `frozen_records`
/// is provided, use that snapshot instead of re-querying the store.
pub fn with_frozen_records(
model: &Model,
view: &View,
frozen_records: Option<Rc<Vec<(CellKey, CellValue)>>>,
) -> Self {
let mut layout = Self::new(model, view);
if layout.is_records_mode() {
if let Some(records) = frozen_records {
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
layout.row_items = row_items;
layout.records = Some(records);
}
}
if view.prune_empty {
layout.prune_empty(model);
}
layout
}
pub fn new(model: &Model, view: &View) -> Self {
let row_cats: Vec<String> = view
.categories_on(Axis::Row)
@ -84,16 +46,19 @@ impl GridLayout {
.into_iter()
.map(String::from)
.collect();
let none_cats: Vec<String> = view
.categories_on(Axis::None)
.into_iter()
.map(String::from)
.collect();
let page_coords = page_cats
.iter()
.map(|cat| {
let items: Vec<String> = model.effective_item_names(cat);
let items: Vec<String> = model
.category(cat)
.map(|c| {
c.ordered_item_names()
.into_iter()
.map(String::from)
.collect()
})
.unwrap_or_default();
let sel = view
.page_selection(cat)
.map(String::from)
@ -103,205 +68,17 @@ impl GridLayout {
})
.collect();
// Detect records mode: _Index on Row and _Dim on Col
let is_records_mode =
row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");
if is_records_mode {
Self::build_records_mode(model, view, page_coords, none_cats)
} else {
let row_items = cross_product(model, view, &row_cats);
let col_items = cross_product(model, view, &col_cats);
Self {
row_cats,
col_cats,
page_coords,
row_items,
col_items,
none_cats,
records: None,
}
}
}
/// Build a records-mode layout: rows are individual cells, columns are
/// category names + "Value". Cells matching the page filter are enumerated.
fn build_records_mode(
model: &Model,
_view: &View,
page_coords: Vec<(String, String)>,
none_cats: Vec<String>,
) -> Self {
// Filter cells by page_coords
let partial: Vec<(String, String)> = page_coords.clone();
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
model
.data
.matching_cells(&partial)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
// Sort for deterministic ordering
records.sort_by(|a, b| a.0 .0.cmp(&b.0 .0));
// Synthesize row items: one per record, labeled with its index
let row_items: Vec<AxisEntry> = (0..records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
// Synthesize col items: one per non-virtual category + "Value"
let cat_names: Vec<String> = model
.category_names()
.into_iter()
.filter(|c| !c.starts_with('_'))
.map(String::from)
.collect();
let mut col_items: Vec<AxisEntry> = cat_names
.iter()
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
.collect();
col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));
Self {
row_cats: vec!["_Index".to_string()],
col_cats: vec!["_Dim".to_string()],
page_coords,
row_items,
col_items,
none_cats,
records: Some(Rc::new(records)),
}
}
/// Get the display string for the cell at (row, col) in records mode.
/// Returns None for normal (non-records) layouts.
pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
let records = self.records.as_ref()?;
let record = records.get(row)?;
let col_item = self.col_label(col);
if col_item == "Value" {
Some(record.1.to_string())
} else {
// col_item is a category name
let found = record
.0
.0
.iter()
.find(|(c, _)| c == &col_item)
.map(|(_, v)| v.clone());
Some(found.unwrap_or_default())
}
}
/// Remove data rows where every column is empty and data columns
/// where every row is empty. Group headers are kept if at least one
/// of their data items survives.
///
/// In records mode every column is shown (the user drilled in to see
/// all the raw data). In pivot mode, rows and columns where every
/// cell is empty are hidden to reduce clutter.
pub fn prune_empty(&mut self, model: &Model) {
if self.is_records_mode() {
return;
}
let rc = self.row_count();
let cc = self.col_count();
if rc == 0 || cc == 0 {
return;
}
// Build a row×col grid of "has content?"
let mut has_value = vec![vec![false; cc]; rc];
for (ri, row) in has_value.iter_mut().enumerate() {
for (ci, cell) in row.iter_mut().enumerate() {
*cell = self
.cell_key(ri, ci)
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
.is_some();
}
}
// Which data-row indices are non-empty?
let keep_row: Vec<bool> = (0..rc)
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
.collect();
// Which data-col indices are non-empty?
let keep_col: Vec<bool> = (0..cc)
.map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
.collect();
// Filter row_items, preserving group headers when at least one
// subsequent data item survives.
let mut new_rows = Vec::new();
let mut pending_header: Option<AxisEntry> = None;
let mut data_idx = 0usize;
for entry in self.row_items.drain(..) {
match &entry {
AxisEntry::GroupHeader { .. } => {
pending_header = Some(entry);
}
AxisEntry::DataItem(_) => {
if data_idx < rc && keep_row[data_idx] {
if let Some(h) = pending_header.take() {
new_rows.push(h);
}
new_rows.push(entry);
}
data_idx += 1;
}
}
}
self.row_items = new_rows;
// Filter col_items (same logic)
let mut new_cols = Vec::new();
let mut pending_header: Option<AxisEntry> = None;
let mut data_idx = 0usize;
for entry in self.col_items.drain(..) {
match &entry {
AxisEntry::GroupHeader { .. } => {
pending_header = Some(entry);
}
AxisEntry::DataItem(_) => {
if data_idx < cc && keep_col[data_idx] {
if let Some(h) = pending_header.take() {
new_cols.push(h);
}
new_cols.push(entry);
}
data_idx += 1;
}
}
}
self.col_items = new_cols;
// If records mode, also prune the records vec and re-index row_items
if let Some(records) = &self.records {
let new_records: Vec<_> = keep_row
.iter()
.enumerate()
.filter(|(_, keep)| **keep)
.map(|(i, _)| records[i].clone())
.collect();
let new_row_items: Vec<AxisEntry> = (0..new_records.len())
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
.collect();
self.row_items = new_row_items;
self.records = Some(Rc::new(new_records));
}
}
/// Whether this layout is in records mode.
pub fn is_records_mode(&self) -> bool {
self.records.is_some()
}
/// Number of data rows (group headers excluded).
pub fn row_count(&self) -> usize {
@ -349,57 +126,9 @@ impl GridLayout {
.unwrap_or_default()
}
/// Resolve the display string for a synthetic records-mode CellKey.
/// Returns None for non-synthetic (pivot) keys.
pub fn resolve_display(&self, key: &CellKey) -> Option<String> {
let (idx, dim) = synthetic_record_info(key)?;
let records = self.records.as_ref()?;
let (orig_key, value) = records.get(idx)?;
if dim == "Value" {
Some(value.to_string())
} else {
Some(orig_key.get(&dim).unwrap_or("").to_string())
}
}
/// Unified display text for a cell at (row, col). Handles both pivot and
/// records modes. In pivot mode, evaluates and formats the cell value.
/// In records mode, resolves via the frozen records snapshot.
pub fn display_text(
&self,
model: &Model,
row: usize,
col: usize,
fmt_comma: bool,
fmt_decimals: u8,
) -> String {
if self.is_records_mode() {
self.records_display(row, col).unwrap_or_default()
} else {
self.cell_key(row, col)
.and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
.unwrap_or_default()
}
}
/// Build the CellKey for the data cell at (row, col), including the active
/// page-axis filter. Returns None if row or col is out of bounds.
/// In records mode: returns a synthetic `(_Index, _Dim)` key for every column.
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
if let Some(records) = &self.records {
if row >= records.len() {
return None;
}
let col_label = self.col_label(col);
if col_label.is_empty() {
return None;
}
return Some(CellKey::new(vec![
("_Index".to_string(), row.to_string()),
("_Dim".to_string(), col_label),
]));
}
let row_item = self
.row_items
.iter()
@ -459,40 +188,6 @@ impl GridLayout {
}
None
}
/// Find the group containing the Nth data row.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> {
let vi = self.data_row_to_visual(data_row)?;
self.row_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
/// Find the group containing the Nth data column.
/// Returns `(cat_name, group_name)` of the nearest preceding GroupHeader.
pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> {
let vi = self.data_col_to_visual(data_col)?;
self.col_items[..vi].iter().rev().find_map(|e| {
if let AxisEntry::GroupHeader {
cat_name,
group_name,
} = e
{
Some((cat_name.clone(), group_name.clone()))
} else {
None
}
})
}
}
/// Expand a single category into `AxisEntry` values, given a coordinate prefix.
@ -510,13 +205,11 @@ fn expand_category(
let mut result = Vec::new();
let mut last_group: Option<&str> = None;
// Use effective_item_names so _Measure includes formula targets dynamically
let effective_names = model.effective_item_names(cat_name);
for item_name in &effective_names {
for item_name in cat.ordered_item_names() {
if view.is_hidden(cat_name, item_name) {
continue;
}
let item_group = cat.items.get(item_name.as_str()).and_then(|i| i.group.as_deref());
let item_group = cat.items.get(item_name).and_then(|i| i.group.as_deref());
// Emit a group header at each group boundary.
if item_group != last_group {
@ -530,7 +223,7 @@ fn expand_category(
}
// Skip the data item if its group is collapsed.
if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) {
if item_group.map_or(false, |g| view.is_group_collapsed(cat_name, g)) {
continue;
}
@ -564,136 +257,9 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec<AxisEntry>
#[cfg(test)]
mod tests {
use super::{synthetic_record_info, AxisEntry, GridLayout};
use super::{AxisEntry, GridLayout};
use crate::model::cell::{CellKey, CellValue};
use crate::model::Model;
use crate::view::Axis;
fn records_model() -> Model {
let mut m = Model::new("T");
m.add_category("Region").unwrap();
m.add_category("_Measure").unwrap();
m.category_mut("Region").unwrap().add_item("North");
m.category_mut("_Measure").unwrap().add_item("Revenue");
m.category_mut("_Measure").unwrap().add_item("Cost");
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Revenue".into()),
]),
CellValue::Number(100.0),
);
m.set_cell(
CellKey::new(vec![
("Region".into(), "North".into()),
("_Measure".into(), "Cost".into()),
]),
CellValue::Number(50.0),
);
m
}
#[test]
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
let mut m = Model::new("T");
m.add_category("Row").unwrap();
m.add_category("Col").unwrap();
m.category_mut("Row").unwrap().add_item("A");
m.category_mut("Col").unwrap().add_item("X");
m.category_mut("Col").unwrap().add_item("Y");
// Only X has data; Y is entirely empty
m.set_cell(
CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]),
CellValue::Number(1.0),
);
let mut layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.col_count(), 2); // X and Y before pruning
layout.prune_empty(&m);
assert_eq!(layout.col_count(), 1); // only X after pruning
assert_eq!(layout.col_label(0), "X");
}
#[test]
fn records_mode_activated_when_index_and_dim_on_axes() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode());
assert_eq!(layout.row_count(), 2); // 2 cells
}
#[test]
fn records_mode_cell_key_returns_synthetic_for_all_columns() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
assert!(layout.is_records_mode());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
.collect();
// All columns return synthetic keys
let value_col = cols.iter().position(|c| c == "Value").unwrap();
let key = layout.cell_key(0, value_col).unwrap();
assert_eq!(key.get("_Index"), Some("0"));
assert_eq!(key.get("_Dim"), Some("Value"));
let region_col = cols.iter().position(|c| c == "Region").unwrap();
let key = layout.cell_key(0, region_col).unwrap();
assert_eq!(key.get("_Index"), Some("0"));
assert_eq!(key.get("_Dim"), Some("Region"));
}
#[test]
fn records_mode_resolve_display_returns_values() {
let mut m = records_model();
let v = m.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
let layout = GridLayout::new(&m, m.active_view());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
.collect();
// Value column resolves to the cell value
let value_col = cols.iter().position(|c| c == "Value").unwrap();
let key = layout.cell_key(0, value_col).unwrap();
let display = layout.resolve_display(&key);
assert!(display.is_some(), "Value column should resolve");
// Category column resolves to the coordinate value
let region_col = cols.iter().position(|c| c == "Region").unwrap();
let key = layout.cell_key(0, region_col).unwrap();
let display = layout.resolve_display(&key).unwrap();
assert!(
!display.is_empty(),
"Region column should resolve to a value"
);
}
#[test]
fn synthetic_record_info_returns_none_for_pivot_keys() {
let key = CellKey::new(vec![
("Region".to_string(), "East".to_string()),
("Product".to_string(), "Shoes".to_string()),
]);
assert!(synthetic_record_info(&key).is_none());
}
#[test]
fn synthetic_record_info_extracts_index_and_dim() {
let key = CellKey::new(vec![
("_Index".to_string(), "3".to_string()),
("_Dim".to_string(), "Region".to_string()),
]);
let (idx, dim) = synthetic_record_info(&key).unwrap();
assert_eq!(idx, 3);
assert_eq!(dim, "Region");
}
fn coord(pairs: &[(&str, &str)]) -> CellKey {
CellKey::new(
@ -917,91 +483,4 @@ mod tests {
assert_eq!(layout.data_row_to_visual(1), Some(3)); // Apr is at visual index 3
assert_eq!(layout.data_row_to_visual(2), None);
}
#[test]
fn data_col_to_visual_skips_headers() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
// col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)]
assert_eq!(layout.data_col_to_visual(0), Some(1));
assert_eq!(layout.data_col_to_visual(1), Some(3));
assert_eq!(layout.data_col_to_visual(2), None);
}
#[test]
fn row_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Month").unwrap();
m.add_category("Type").unwrap();
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
m.category_mut("Type").unwrap().add_item("Food");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.row_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.row_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn row_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.row_group_for(0), None);
}
#[test]
fn col_group_for_finds_enclosing_group() {
let mut m = Model::new("T");
m.add_category("Type").unwrap(); // Row
m.add_category("Month").unwrap(); // Column
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Jan", "Q1");
m.category_mut("Month")
.unwrap()
.add_item_in_group("Apr", "Q2");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(
layout.col_group_for(0),
Some(("Month".to_string(), "Q1".to_string()))
);
assert_eq!(
layout.col_group_for(1),
Some(("Month".to_string(), "Q2".to_string()))
);
}
#[test]
fn col_group_for_returns_none_for_ungrouped() {
let mut m = Model::new("T");
m.add_category("Type").unwrap();
m.add_category("Month").unwrap();
m.category_mut("Type").unwrap().add_item("Food");
m.category_mut("Month").unwrap().add_item("Jan");
let layout = GridLayout::new(&m, m.active_view());
assert_eq!(layout.col_group_for(0), None);
}
}

View File

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

View File

@ -4,10 +4,6 @@ use std::collections::{HashMap, HashSet};
use super::axis::Axis;
fn default_prune() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct View {
pub name: String,
@ -21,9 +17,6 @@ pub struct View {
pub collapsed_groups: HashMap<String, HashSet<String>>,
/// Number format string (e.g. ",.0f" for comma-separated integer)
pub number_format: String,
/// When true, empty rows/columns are pruned from the display.
#[serde(default = "default_prune")]
pub prune_empty: bool,
/// Scroll offset for grid
pub row_offset: usize,
pub col_offset: usize,
@ -40,7 +33,6 @@ impl View {
hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(),
number_format: ",.0".to_string(),
prune_empty: false,
row_offset: 0,
col_offset: 0,
selected: (0, 0),
@ -49,62 +41,20 @@ impl View {
pub fn on_category_added(&mut self, cat_name: &str) {
if !self.category_axes.contains_key(cat_name) {
// Virtual/underscore categories default to Axis::None.
// Regular categories auto-assign: first → Row, second → Column, rest → Page.
// If a virtual currently holds Row or Column and a regular category needs
// the slot, bump the virtual to None.
let axis = if cat_name.starts_with('_') {
Axis::None
} else {
let 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);
}
// Auto-assign: first → Row, second → Column, rest → Page
let rows = self.categories_on(Axis::Row).len();
let cols = self.categories_on(Axis::Column).len();
let axis = if rows == 0 {
Axis::Row
} else if 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);
}
} else if cols == 0 {
Axis::Column
} else {
Axis::Page
}
};
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) {
if let Some(a) = self.category_axes.get_mut(cat_name) {
*a = axis;
@ -198,13 +148,12 @@ impl View {
self.col_offset = 0;
}
/// Cycle axis for a category: Row → Column → Page → None → Row
/// Cycle axis for a category: Row → Column → Page → Row
pub fn cycle_axis(&mut self, cat_name: &str) {
let next = match self.axis_of(cat_name) {
Axis::Row => Axis::Column,
Axis::Column => Axis::Page,
Axis::Page => Axis::None,
Axis::None => Axis::Row,
Axis::Page => Axis::Row,
};
self.set_axis(cat_name, next);
self.selected = (0, 0);
@ -353,17 +302,9 @@ mod tests {
}
#[test]
fn cycle_axis_page_to_none() {
fn cycle_axis_page_to_row() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::None);
}
#[test]
fn cycle_axis_none_to_row() {
let mut v = view_with_cats(&["Region", "Product", "Time"]);
v.set_axis("Time", Axis::None);
v.cycle_axis("Time");
assert_eq!(v.axis_of("Time"), Axis::Row);
}
@ -410,7 +351,7 @@ mod prop_tests {
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
let all_axes = [Axis::Row, Axis::Column, Axis::Page];
for c in &cats {
let count = all_axes.iter()
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
@ -436,7 +377,7 @@ mod prop_tests {
fn set_axis_updates_axis_of(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }
@ -451,7 +392,7 @@ mod prop_tests {
fn set_axis_exclusive(
cats in unique_cat_names(),
target_idx in 0usize..8,
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
) {
let mut v = View::new("T");
for c in &cats { v.on_category_added(c); }