Refactor: split ImportWizard into pure ImportPipeline and UI wrapper

ImportPipeline holds all data and logic (raw JSON, records, proposals,
model_name, build_model). ImportWizard wraps it with UI-only state
(step, cursor, message). Rename WizardState → WizardStep throughout.
Headless import in dispatch.rs now constructs ImportPipeline directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-21 23:28:27 -07:00
parent 413601517d
commit 197b66e4e1
5 changed files with 303 additions and 169 deletions

View File

@ -196,20 +196,17 @@ fn import_json_headless(
let proposals = analyze_records(&records); let proposals = analyze_records(&records);
// Auto-accept all and build // Auto-accept all and build via ImportPipeline
let wizard = crate::import::wizard::ImportWizard { let pipeline = crate::import::wizard::ImportPipeline {
state: crate::import::wizard::WizardState::NameModel,
raw: value, raw: value,
array_paths: vec![], array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(), selected_path: array_path.unwrap_or("").to_string(),
records, records,
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(), 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(), model_name: model_name.unwrap_or("Imported Model").to_string(),
cursor: 0,
message: None,
}; };
match wizard.build_model() { match pipeline.build_model() {
Ok(new_model) => { Ok(new_model) => {
*model = new_model; *model = new_model;
CommandResult::ok_msg("JSON imported successfully") CommandResult::ok_msg("JSON imported successfully")

View File

@ -1,5 +1,5 @@
pub mod wizard; pub mod wizard;
pub mod analyzer; pub mod analyzer;
pub use wizard::{ImportWizard, WizardState}; pub use wizard::{ImportWizard, ImportPipeline, WizardStep};
pub use analyzer::{FieldKind, FieldProposal, analyze_records}; pub use analyzer::{FieldKind, FieldProposal, analyze_records};

View File

@ -5,70 +5,44 @@ use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_a
use crate::model::Model; use crate::model::Model;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
#[derive(Debug, Clone, PartialEq)] // ── Pipeline (no UI state) ────────────────────────────────────────────────────
pub enum WizardState {
Preview,
SelectArrayPath,
ReviewProposals,
NameModel,
Done,
}
/// Pure data + logic for turning a JSON value into a Model.
/// No cursor, no display messages — those live in [`ImportWizard`].
#[derive(Debug)] #[derive(Debug)]
pub struct ImportWizard { pub struct ImportPipeline {
pub state: WizardState,
pub raw: Value, pub raw: Value,
pub array_paths: Vec<String>, pub array_paths: Vec<String>,
pub selected_path: String, pub selected_path: String,
pub records: Vec<Value>, pub records: Vec<Value>,
pub proposals: Vec<FieldProposal>, pub proposals: Vec<FieldProposal>,
pub model_name: String, pub model_name: String,
pub cursor: usize,
/// Message to display
pub message: Option<String>,
} }
impl ImportWizard { impl ImportPipeline {
pub fn new(raw: Value) -> Self { pub fn new(raw: Value) -> Self {
let array_paths = find_array_paths(&raw); let array_paths = find_array_paths(&raw);
let state = if raw.is_array() { let mut pipeline = Self {
WizardState::ReviewProposals
} else if array_paths.len() == 1 {
WizardState::ReviewProposals
} else {
WizardState::SelectArrayPath
};
let mut wizard = Self {
state: WizardState::Preview,
raw: raw.clone(), raw: raw.clone(),
array_paths: array_paths.clone(), array_paths,
selected_path: String::new(), selected_path: String::new(),
records: vec![], records: vec![],
proposals: vec![], proposals: vec![],
model_name: "Imported Model".to_string(), model_name: "Imported Model".to_string(),
cursor: 0,
message: None,
}; };
// Auto-select if array at root or single path // Auto-select if root is an array or there is exactly one candidate path.
if raw.is_array() { if raw.is_array() {
wizard.select_path(""); pipeline.select_path("");
} else if array_paths.len() == 1 { } else if pipeline.array_paths.len() == 1 {
let path = array_paths[0].clone(); let path = pipeline.array_paths[0].clone();
wizard.select_path(&path); pipeline.select_path(&path);
} }
wizard.state = if wizard.records.is_empty() && raw.is_object() { pipeline
WizardState::SelectArrayPath
} else {
wizard.advance();
return wizard;
};
wizard
} }
fn select_path(&mut self, path: &str) { pub fn select_path(&mut self, path: &str) {
self.selected_path = path.to_string(); self.selected_path = path.to_string();
if let Some(arr) = extract_array_at_path(&self.raw, path) { if let Some(arr) = extract_array_at_path(&self.raw, path) {
self.records = arr.clone(); self.records = arr.clone();
@ -76,76 +50,31 @@ impl ImportWizard {
} }
} }
pub fn advance(&mut self) { pub fn needs_path_selection(&self) -> bool {
self.state = match self.state { self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1
WizardState::Preview => {
if self.array_paths.len() <= 1 {
WizardState::ReviewProposals
} else {
WizardState::SelectArrayPath
}
}
WizardState::SelectArrayPath => WizardState::ReviewProposals,
WizardState::ReviewProposals => WizardState::NameModel,
WizardState::NameModel => WizardState::Done,
WizardState::Done => WizardState::Done,
};
self.cursor = 0;
self.message = None;
} }
pub fn confirm_path(&mut self) { pub fn preview_summary(&self) -> String {
if self.cursor < self.array_paths.len() { match &self.raw {
let path = self.array_paths[self.cursor].clone(); Value::Array(arr) => format!(
self.select_path(&path); "Array of {} records. Sample keys: {}",
self.advance(); arr.len(),
arr.first()
.and_then(|r| r.as_object())
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default()
),
Value::Object(map) => format!(
"Object with {} top-level keys: {}",
map.len(),
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
),
_ => "Unknown JSON structure".to_string(),
} }
} }
pub fn toggle_proposal(&mut self) { /// Build a Model from the current proposals. Pure — no side effects.
if self.cursor < self.proposals.len() {
self.proposals[self.cursor].accepted = !self.proposals[self.cursor].accepted;
}
}
pub fn cycle_proposal_kind(&mut self) {
if self.cursor < self.proposals.len() {
let p = &mut self.proposals[self.cursor];
p.kind = match p.kind {
FieldKind::Category => FieldKind::Measure,
FieldKind::Measure => FieldKind::TimeCategory,
FieldKind::TimeCategory => FieldKind::Label,
FieldKind::Label => FieldKind::Category,
};
}
}
pub fn move_cursor(&mut self, delta: i32) {
let len = match self.state {
WizardState::SelectArrayPath => self.array_paths.len(),
WizardState::ReviewProposals => self.proposals.len(),
_ => 0,
};
if len == 0 { return; }
if delta > 0 {
self.cursor = (self.cursor + 1).min(len - 1);
} else if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn push_name_char(&mut self, c: char) {
self.model_name.push(c);
}
pub fn pop_name_char(&mut self) {
self.model_name.pop();
}
pub fn build_model(&self) -> Result<Model> { pub fn build_model(&self) -> Result<Model> {
let mut model = Model::new(self.model_name.clone());
// Collect categories and measures from accepted proposals
let categories: Vec<&FieldProposal> = self.proposals.iter() let categories: Vec<&FieldProposal> = self.proposals.iter()
.filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory)) .filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory))
.collect(); .collect();
@ -157,7 +86,8 @@ impl ImportWizard {
return Err(anyhow!("At least one category must be accepted")); return Err(anyhow!("At least one category must be accepted"));
} }
// Add categories let mut model = Model::new(&self.model_name);
for cat_proposal in &categories { for cat_proposal in &categories {
model.add_category(&cat_proposal.field)?; model.add_category(&cat_proposal.field)?;
if let Some(cat) = model.category_mut(&cat_proposal.field) { if let Some(cat) = model.category_mut(&cat_proposal.field) {
@ -167,7 +97,6 @@ impl ImportWizard {
} }
} }
// If there are measures, add a "Measure" category
if !measures.is_empty() { if !measures.is_empty() {
model.add_category("Measure")?; model.add_category("Measure")?;
if let Some(cat) = model.category_mut("Measure") { if let Some(cat) = model.category_mut("Measure") {
@ -177,10 +106,8 @@ impl ImportWizard {
} }
} }
// Import records as cells
for record in &self.records { for record in &self.records {
if let Value::Object(map) = record { if let Value::Object(map) = record {
// Build base coordinate from category fields
let mut coords: Vec<(String, String)> = vec![]; let mut coords: Vec<(String, String)> = vec![];
let mut valid = true; let mut valid = true;
@ -191,7 +118,6 @@ impl ImportWizard {
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string())); .or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
if let Some(v) = val { if let Some(v) = val {
// Ensure item exists
if let Some(cat) = model.category_mut(&cat_proposal.field) { if let Some(cat) = model.category_mut(&cat_proposal.field) {
cat.add_item(&v); cat.add_item(&v);
} }
@ -204,15 +130,11 @@ impl ImportWizard {
if !valid { continue; } if !valid { continue; }
// Add each measure as a cell
for measure in &measures { for measure in &measures {
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
let mut cell_coords = coords.clone(); let mut cell_coords = coords.clone();
if !measures.is_empty() {
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));
let key = CellKey::new(cell_coords);
model.set_cell(key, CellValue::Number(val));
} }
} }
} }
@ -220,27 +142,242 @@ impl ImportWizard {
Ok(model) Ok(model)
} }
}
pub fn preview_summary(&self) -> String { // ── Wizard (UI state wrapped around the pipeline) ─────────────────────────────
match &self.raw {
Value::Array(arr) => { #[derive(Debug, Clone, PartialEq)]
format!( pub enum WizardStep {
"Array of {} records. Sample keys: {}", Preview,
arr.len(), SelectArrayPath,
arr.first() ReviewProposals,
.and_then(|r| r.as_object()) NameModel,
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", ")) Done,
.unwrap_or_default()
)
} }
Value::Object(map) => {
format!( /// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard.
"Object with {} top-level keys: {}", /// The pipeline holds all data; the wizard holds only what the UI needs to
map.len(), /// drive the multi-step interaction (current step, list cursor, error message).
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ") #[derive(Debug)]
) pub struct ImportWizard {
pub pipeline: ImportPipeline,
pub step: WizardStep,
/// Cursor within the current list (array paths or proposals).
pub cursor: usize,
/// One-line message to display at the bottom of the wizard panel.
pub message: Option<String>,
} }
_ => "Unknown JSON structure".to_string(),
impl ImportWizard {
pub fn new(raw: Value) -> Self {
let pipeline = ImportPipeline::new(raw);
let step = if pipeline.needs_path_selection() {
WizardStep::SelectArrayPath
} else if pipeline.records.is_empty() {
WizardStep::Preview
} else {
WizardStep::ReviewProposals
};
Self { pipeline, step, cursor: 0, message: None }
}
// ── Step transitions ──────────────────────────────────────────────────────
pub fn advance(&mut self) {
self.step = match self.step {
WizardStep::Preview => {
if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
WizardStep::SelectArrayPath
} else {
WizardStep::ReviewProposals
} }
} }
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
WizardStep::ReviewProposals => WizardStep::NameModel,
WizardStep::NameModel => WizardStep::Done,
WizardStep::Done => WizardStep::Done,
};
self.cursor = 0;
self.message = None;
}
pub fn confirm_path(&mut self) {
if self.cursor < self.pipeline.array_paths.len() {
let path = self.pipeline.array_paths[self.cursor].clone();
self.pipeline.select_path(&path);
self.advance();
}
}
// ── Cursor movement ───────────────────────────────────────────────────────
pub fn move_cursor(&mut self, delta: i32) {
let len = match self.step {
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
_ => 0,
};
if len == 0 { return; }
if delta > 0 {
self.cursor = (self.cursor + 1).min(len - 1);
} else if self.cursor > 0 {
self.cursor -= 1;
}
}
// ── Proposal editing (delegates to pipeline) ──────────────────────────────
pub fn toggle_proposal(&mut self) {
if self.cursor < self.pipeline.proposals.len() {
self.pipeline.proposals[self.cursor].accepted =
!self.pipeline.proposals[self.cursor].accepted;
}
}
pub fn cycle_proposal_kind(&mut self) {
if self.cursor < self.pipeline.proposals.len() {
let p = &mut self.pipeline.proposals[self.cursor];
p.kind = match p.kind {
FieldKind::Category => FieldKind::Measure,
FieldKind::Measure => FieldKind::TimeCategory,
FieldKind::TimeCategory => FieldKind::Label,
FieldKind::Label => FieldKind::Category,
};
}
}
// ── Model name input ──────────────────────────────────────────────────────
pub fn push_name_char(&mut self, c: char) { self.pipeline.model_name.push(c); }
pub fn pop_name_char(&mut self) { self.pipeline.model_name.pop(); }
// ── Delegate build to pipeline ────────────────────────────────────────────
pub fn build_model(&self) -> Result<Model> {
self.pipeline.build_model()
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::ImportPipeline;
use crate::import::analyzer::FieldKind;
#[test]
fn flat_array_auto_selected() {
let raw = json!([
{"region": "East", "product": "Shirts", "revenue": 100.0},
{"region": "West", "product": "Shirts", "revenue": 200.0},
]);
let p = ImportPipeline::new(raw);
assert!(!p.records.is_empty());
assert!(!p.proposals.is_empty());
assert!(!p.needs_path_selection());
}
#[test]
fn numeric_field_proposed_as_measure() {
let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]);
let p = ImportPipeline::new(raw);
let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap();
assert_eq!(revenue.kind, FieldKind::Measure);
}
#[test]
fn low_cardinality_string_field_proposed_as_category() {
let raw = json!([
{"region": "East", "revenue": 100.0},
{"region": "West", "revenue": 200.0},
{"region": "East", "revenue": 150.0},
]);
let p = ImportPipeline::new(raw);
let region = p.proposals.iter().find(|p| p.field == "region").unwrap();
assert_eq!(region.kind, FieldKind::Category);
}
#[test]
fn nested_json_needs_path_selection_when_multiple_arrays() {
let raw = json!({
"orders": [{"id": 1}, {"id": 2}],
"products": [{"name": "A"}, {"name": "B"}],
});
let p = ImportPipeline::new(raw);
assert!(p.needs_path_selection());
}
#[test]
fn single_nested_array_auto_selected() {
let raw = json!({
"data": [
{"region": "East", "revenue": 100.0},
{"region": "West", "revenue": 200.0},
]
});
let p = ImportPipeline::new(raw);
assert!(!p.records.is_empty());
assert!(!p.needs_path_selection());
}
#[test]
fn select_path_populates_records_and_proposals() {
let raw = json!({
"a": [{"x": 1}, {"x": 2}],
"b": [{"y": "foo"}, {"y": "bar"}],
});
let mut p = ImportPipeline::new(raw);
p.select_path("b");
assert!(!p.records.is_empty());
assert!(!p.proposals.is_empty());
}
#[test]
fn build_model_fails_with_no_accepted_categories() {
let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
let mut p = ImportPipeline::new(raw);
for prop in &mut p.proposals { prop.accepted = false; }
assert!(p.build_model().is_err());
}
#[test]
fn build_model_creates_categories_and_measure_category() {
let raw = json!([
{"region": "East", "revenue": 100.0},
{"region": "West", "revenue": 200.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
assert!(model.category("region").is_some());
assert!(model.category("Measure").is_some());
}
#[test]
fn build_model_cells_match_source_data() {
let raw = json!([
{"region": "East", "revenue": 100.0},
{"region": "West", "revenue": 200.0},
]);
let p = ImportPipeline::new(raw);
let model = p.build_model().unwrap();
use crate::model::cell::CellKey;
let k_east = CellKey::new(vec![
("Measure".to_string(), "revenue".to_string()),
("region".to_string(), "East".to_string()),
]);
let k_west = CellKey::new(vec![
("Measure".to_string(), "revenue".to_string()),
("region".to_string(), "West".to_string()),
]);
assert_eq!(model.get_cell(&k_east).as_f64(), Some(100.0));
assert_eq!(model.get_cell(&k_west).as_f64(), Some(200.0));
}
#[test]
fn model_name_defaults_to_imported_model() {
let raw = json!([{"x": 1.0}]);
let p = ImportPipeline::new(raw);
assert_eq!(p.model_name, "Imported Model");
}
} }

View File

@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::model::Model; use crate::model::Model;
use crate::model::cell::{CellKey, CellValue}; use crate::model::cell::{CellKey, CellValue};
use crate::formula::parse_formula; use crate::formula::parse_formula;
use crate::import::wizard::{ImportWizard, WizardState}; use crate::import::wizard::{ImportWizard, WizardStep};
use crate::persistence; use crate::persistence;
use crate::view::Axis; use crate::view::Axis;
use crate::command::{self, Command}; use crate::command::{self, Command};
@ -846,20 +846,20 @@ impl App {
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> { fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
if let Some(wizard) = &mut self.wizard { if let Some(wizard) = &mut self.wizard {
match &wizard.state.clone() { match &wizard.step.clone() {
WizardState::Preview => match key.code { WizardStep::Preview => match key.code {
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(), KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {} _ => {}
}, },
WizardState::SelectArrayPath => match key.code { WizardStep::SelectArrayPath => match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Enter => wizard.confirm_path(), KeyCode::Enter => wizard.confirm_path(),
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {} _ => {}
}, },
WizardState::ReviewProposals => match key.code { WizardStep::ReviewProposals => match key.code {
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1), KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1), KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
KeyCode::Char(' ') => wizard.toggle_proposal(), KeyCode::Char(' ') => wizard.toggle_proposal(),
@ -868,7 +868,7 @@ impl App {
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {} _ => {}
}, },
WizardState::NameModel => match key.code { WizardStep::NameModel => match key.code {
KeyCode::Char(c) => wizard.push_name_char(c), KeyCode::Char(c) => wizard.push_name_char(c),
KeyCode::Backspace => wizard.pop_name_char(), KeyCode::Backspace => wizard.pop_name_char(),
KeyCode::Enter => { KeyCode::Enter => {
@ -890,7 +890,7 @@ impl App {
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; } KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
_ => {} _ => {}
}, },
WizardState::Done => { self.mode = AppMode::Normal; self.wizard = None; } WizardStep::Done => { self.mode = AppMode::Normal; self.wizard = None; }
} }
} }
Ok(()) Ok(())

View File

@ -5,7 +5,7 @@ use ratatui::{
widgets::{Block, Borders, Clear, Widget}, widgets::{Block, Borders, Clear, Widget},
}; };
use crate::import::wizard::{ImportWizard, WizardState}; use crate::import::wizard::{ImportWizard, WizardStep};
use crate::import::analyzer::FieldKind; use crate::import::analyzer::FieldKind;
pub struct ImportWizardWidget<'a> { pub struct ImportWizardWidget<'a> {
@ -28,12 +28,12 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Clear.render(popup_area, buf); Clear.render(popup_area, buf);
let title = match self.wizard.state { let title = match self.wizard.step {
WizardState::Preview => " Import Wizard — Step 1: Preview ", WizardStep::Preview => " Import Wizard — Step 1: Preview ",
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ", WizardStep::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ", WizardStep::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
WizardState::NameModel => " Import Wizard — Step 4: Name Model ", WizardStep::NameModel => " Import Wizard — Step 4: Name Model ",
WizardState::Done => " Import Wizard — Done ", WizardStep::Done => " Import Wizard — Done ",
}; };
let block = Block::default() let block = Block::default()
@ -47,21 +47,21 @@ impl<'a> Widget for ImportWizardWidget<'a> {
let x = inner.x; let x = inner.x;
let w = inner.width as usize; let w = inner.width as usize;
match &self.wizard.state { match &self.wizard.step {
WizardState::Preview => { WizardStep::Preview => {
let summary = self.wizard.preview_summary(); let summary = self.wizard.pipeline.preview_summary();
buf.set_string(x, y, truncate(&summary, w), Style::default()); buf.set_string(x, y, truncate(&summary, w), Style::default());
y += 2; y += 2;
buf.set_string(x, y, buf.set_string(x, y,
"Press Enter to continue", "Press Enter to continue\u{2026}",
Style::default().fg(Color::Yellow)); Style::default().fg(Color::Yellow));
} }
WizardState::SelectArrayPath => { WizardStep::SelectArrayPath => {
buf.set_string(x, y, buf.set_string(x, y,
"Select the path containing records:", "Select the path containing records:",
Style::default().fg(Color::Yellow)); Style::default().fg(Color::Yellow));
y += 1; y += 1;
for (i, path) in self.wizard.array_paths.iter().enumerate() { for (i, path) in self.wizard.pipeline.array_paths.iter().enumerate() {
if y >= inner.y + inner.height { break; } if y >= inner.y + inner.height { break; }
let is_sel = i == self.wizard.cursor; let is_sel = i == self.wizard.cursor;
let style = if is_sel { let style = if is_sel {
@ -74,9 +74,9 @@ impl<'a> Widget for ImportWizardWidget<'a> {
y += 1; y += 1;
} }
y += 1; y += 1;
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray)); buf.set_string(x, y, "\u{2191}\u{2193} select Enter confirm", Style::default().fg(Color::DarkGray));
} }
WizardState::ReviewProposals => { WizardStep::ReviewProposals => {
buf.set_string(x, y, buf.set_string(x, y,
"Review field proposals (Space toggle, c cycle kind):", "Review field proposals (Space toggle, c cycle kind):",
Style::default().fg(Color::Yellow)); Style::default().fg(Color::Yellow));
@ -85,7 +85,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED)); buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
y += 1; y += 1;
for (i, proposal) in self.wizard.proposals.iter().enumerate() { for (i, proposal) in self.wizard.pipeline.proposals.iter().enumerate() {
if y >= inner.y + inner.height - 2 { break; } if y >= inner.y + inner.height - 2 { break; }
let is_sel = i == self.wizard.cursor; let is_sel = i == self.wizard.cursor;
@ -96,7 +96,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
FieldKind::Label => Color::DarkGray, FieldKind::Label => Color::DarkGray,
}; };
let accept_str = if proposal.accepted { "[]" } else { "[ ]" }; let accept_str = if proposal.accepted { "[\u{2713}]" } else { "[ ]" };
let row = format!(" {:<20} {:<22} {}", let row = format!(" {:<20} {:<22} {}",
truncate(&proposal.field, 20), truncate(&proposal.field, 20),
truncate(proposal.kind_label(), 22), truncate(proposal.kind_label(), 22),
@ -117,10 +117,10 @@ impl<'a> Widget for ImportWizardWidget<'a> {
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel", buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
Style::default().fg(Color::DarkGray)); Style::default().fg(Color::DarkGray));
} }
WizardState::NameModel => { WizardStep::NameModel => {
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow)); buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
y += 1; y += 1;
let name_str = format!("> {}", self.wizard.model_name); let name_str = format!("> {}\u{2588}", self.wizard.pipeline.model_name);
buf.set_string(x, y, truncate(&name_str, w), buf.set_string(x, y, truncate(&name_str, w),
Style::default().fg(Color::Green)); Style::default().fg(Color::Green));
y += 2; y += 2;
@ -133,7 +133,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
Style::default().fg(Color::Red)); Style::default().fg(Color::Red));
} }
} }
WizardState::Done => { WizardStep::Done => {
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green)); buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
} }
} }
@ -142,6 +142,6 @@ impl<'a> Widget for ImportWizardWidget<'a> {
fn truncate(s: &str, max: usize) -> String { fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } if s.len() <= max { s.to_string() }
else if max > 1 { format!("{}", &s[..max-1]) } else if max > 1 { format!("{}\u{2026}", &s[..max-1]) }
else { s[..max].to_string() } else { s[..max].to_string() }
} }