Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

147
src/ui/import_wizard_ui.rs Normal file
View File

@ -0,0 +1,147 @@
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::wizard::{ImportWizard, WizardState};
use crate::import::analyzer::FieldKind;
pub struct ImportWizardWidget<'a> {
pub wizard: &'a ImportWizard,
}
impl<'a> ImportWizardWidget<'a> {
pub fn new(wizard: &'a ImportWizard) -> Self {
Self { wizard }
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
let popup_h = area.height.min(30);
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 title = match self.wizard.state {
WizardState::Preview => " Import Wizard — Step 1: Preview ",
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardState::NameModel => " Import Wizard — Step 4: Name Model ",
WizardState::Done => " Import Wizard — Done ",
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(title);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let mut y = inner.y;
let x = inner.x;
let w = inner.width as usize;
match &self.wizard.state {
WizardState::Preview => {
let summary = self.wizard.preview_summary();
buf.set_string(x, y, truncate(&summary, w), Style::default());
y += 2;
buf.set_string(x, y,
"Press Enter to continue…",
Style::default().fg(Color::Yellow));
}
WizardState::SelectArrayPath => {
buf.set_string(x, y,
"Select the path containing records:",
Style::default().fg(Color::Yellow));
y += 1;
for (i, path) in self.wizard.array_paths.iter().enumerate() {
if y >= inner.y + inner.height { break; }
let is_sel = i == self.wizard.cursor;
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Magenta).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = format!(" {}", if path.is_empty() { "(root)" } else { path });
buf.set_string(x, y, truncate(&label, w), style);
y += 1;
}
y += 1;
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray));
}
WizardState::ReviewProposals => {
buf.set_string(x, y,
"Review field proposals (Space toggle, c cycle kind):",
Style::default().fg(Color::Yellow));
y += 1;
let header = format!(" {:<20} {:<22} {:<6}", "Field", "Kind", "Accept");
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
y += 1;
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
if y >= inner.y + inner.height - 2 { break; }
let is_sel = i == self.wizard.cursor;
let kind_color = match proposal.kind {
FieldKind::Category => Color::Green,
FieldKind::Measure => Color::Cyan,
FieldKind::TimeCategory => Color::Magenta,
FieldKind::Label => Color::DarkGray,
};
let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" };
let row = format!(" {:<20} {:<22} {}",
truncate(&proposal.field, 20),
truncate(proposal.kind_label(), 22),
accept_str);
let style = if is_sel {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if proposal.accepted {
Style::default().fg(kind_color)
} else {
Style::default().fg(Color::DarkGray)
};
buf.set_string(x, y, truncate(&row, w), style);
y += 1;
}
let hint_y = inner.y + inner.height - 1;
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
Style::default().fg(Color::DarkGray));
}
WizardState::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1;
let name_str = format!("> {}", self.wizard.model_name);
buf.set_string(x, y, truncate(&name_str, w),
Style::default().fg(Color::Green));
y += 2;
buf.set_string(x, y, "Enter to import, Esc to cancel",
Style::default().fg(Color::DarkGray));
if let Some(msg) = &self.wizard.message {
let msg_y = inner.y + inner.height - 1;
buf.set_string(x, msg_y, truncate(msg, w),
Style::default().fg(Color::Red));
}
}
WizardState::Done => {
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
}
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() }
else if max > 1 { format!("{}", &s[..max-1]) }
else { s[..max].to_string() }
}