Files
improvise/src/ui/import_wizard_ui.rs
Edward Langley 4233d3fbf4 feat: wizard UI for date config and formula steps
Add key handling for ConfigureDates (space toggle components) and
DefineFormulas (n new, d delete, text input mode) wizard steps.
Render date component toggles, formula list with input area, and
sample formulas derived from detected measures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:41:18 -07:00

348 lines
13 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Clear, Widget},
};
use crate::import::analyzer::{DateComponent, FieldKind};
use crate::import::wizard::{ImportWizard, WizardStep};
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.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::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.step {
WizardStep::Preview => {
let summary = self.wizard.pipeline.preview_summary();
buf.set_string(x, y, truncate(&summary, w), Style::default());
y += 2;
buf.set_string(
x,
y,
"Press Enter to continue\u{2026}",
Style::default().fg(Color::Yellow),
);
}
WizardStep::SelectArrayPath => {
buf.set_string(
x,
y,
"Select the path containing records:",
Style::default().fg(Color::Yellow),
);
y += 1;
for (i, path) in self.wizard.pipeline.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,
"\u{2191}\u{2193} select Enter confirm",
Style::default().fg(Color::DarkGray),
);
}
WizardStep::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.pipeline.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 {
"[\u{2713}]"
} 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),
);
}
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;
let name_str = format!("> {}\u{2588}", self.wizard.pipeline.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));
}
}
WizardStep::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!("{}\u{2026}", &s[..max - 1])
} else {
s[..max].to_string()
}
}