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:
147
src/ui/import_wizard_ui.rs
Normal file
147
src/ui/import_wizard_ui.rs
Normal 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() }
|
||||
}
|
||||
Reference in New Issue
Block a user