Compare commits
10 Commits
d915908354
...
csv-import
| Author | SHA1 | Date | |
|---|---|---|---|
| ef79a39721 | |||
| 9fc3f0b5d6 | |||
| 3f84ba03cb | |||
| 4b721f7543 | |||
| 6d88de3020 | |||
| 87fd6a1620 | |||
| a57d3ed294 | |||
| bbebc3344c | |||
| ff08e3c2c2 | |||
| 8c84256ebc |
23
Cargo.lock
generated
23
Cargo.lock
generated
@ -161,6 +161,27 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||
dependencies = [
|
||||
"csv-core",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "csv-core"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@ -374,6 +395,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"crossterm",
|
||||
"csv",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
@ -381,6 +403,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
@ -21,9 +21,11 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
unicode-width = "0.2"
|
||||
dirs = "5"
|
||||
csv = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
12789
llama-server.log
Normal file
12789
llama-server.log
Normal file
File diff suppressed because it is too large
Load Diff
@ -163,50 +163,69 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
|
||||
path,
|
||||
model_name,
|
||||
array_path,
|
||||
} => import_json_headless(model, path, model_name.as_deref(), array_path.as_deref()),
|
||||
} => import_headless(model, path, model_name.as_deref(), array_path.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_json_headless(
|
||||
fn import_headless(
|
||||
model: &mut Model,
|
||||
path: &str,
|
||||
model_name: Option<&str>,
|
||||
array_path: Option<&str>,
|
||||
) -> CommandResult {
|
||||
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}")),
|
||||
};
|
||||
let is_csv = path.ends_with(".csv");
|
||||
|
||||
let records = 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}'")),
|
||||
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 if let Some(arr) = value.as_array() {
|
||||
arr.clone()
|
||||
} else {
|
||||
// Find first array
|
||||
let paths = crate::import::analyzer::find_array_paths(&value);
|
||||
if let Some(first) = paths.first() {
|
||||
match extract_array_at_path(&value, first) {
|
||||
// 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("Could not extract records array"),
|
||||
None => return CommandResult::err(format!("No array at path '{ap}'")),
|
||||
}
|
||||
} else if let Some(arr) = value.as_array() {
|
||||
arr.clone()
|
||||
} else {
|
||||
return CommandResult::err("No array found in JSON");
|
||||
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);
|
||||
|
||||
// Auto-accept all and build via ImportPipeline
|
||||
// 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: value,
|
||||
raw,
|
||||
array_paths: vec![],
|
||||
selected_path: array_path.unwrap_or("").to_string(),
|
||||
records,
|
||||
@ -223,7 +242,7 @@ fn import_json_headless(
|
||||
match pipeline.build_model() {
|
||||
Ok(new_model) => {
|
||||
*model = new_model;
|
||||
CommandResult::ok_msg("JSON imported successfully")
|
||||
CommandResult::ok_msg("Imported successfully")
|
||||
}
|
||||
Err(e) => CommandResult::err(e.to_string()),
|
||||
}
|
||||
|
||||
159
src/import/csv_parser.rs
Normal file
159
src/import/csv_parser.rs
Normal file
@ -0,0 +1,159 @@
|
||||
use anyhow::{Context, Result};
|
||||
use csv::ReaderBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Parse a CSV file and return records as serde_json::Value array
|
||||
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}"))?;
|
||||
|
||||
// Detect if first row looks like headers (strings) or data (mixed)
|
||||
let has_headers = reader.headers().is_ok();
|
||||
|
||||
let mut records = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
|
||||
if has_headers {
|
||||
headers = reader
|
||||
.headers()
|
||||
.with_context(|| "Failed to read CSV headers")?
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for result in reader.records() {
|
||||
let record = result.with_context(|| "Failed to read CSV record")?;
|
||||
let mut map = serde_json::Map::new();
|
||||
|
||||
for (i, field) in record.iter().enumerate() {
|
||||
let json_value: Value = parse_csv_field(field);
|
||||
if has_headers {
|
||||
if let Some(header) = headers.get(i) {
|
||||
map.insert(header.clone(), json_value);
|
||||
}
|
||||
} else {
|
||||
map.insert(i.to_string(), json_value);
|
||||
}
|
||||
}
|
||||
|
||||
if !map.is_empty() {
|
||||
records.push(Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn parse_csv_field(field: &str) -> Value {
|
||||
if field.is_empty() {
|
||||
return Value::Null;
|
||||
}
|
||||
|
||||
// Try to parse as number (integer or float)
|
||||
if let Ok(num) = field.parse::<i64>() {
|
||||
return Value::Number(serde_json::Number::from(num));
|
||||
}
|
||||
|
||||
if let Ok(num) = field.parse::<f64>() {
|
||||
return Value::Number(
|
||||
serde_json::Number::from_f64(num).unwrap_or(serde_json::Number::from(0)),
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as string
|
||||
Value::String(field.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use 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.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 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)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_floats() {
|
||||
let (path, _dir) =
|
||||
create_temp_csv("Region,Revenue,Cost\nEast,1000.50,600.25\nWest,800.75,500.00");
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_quoted_fields() {
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_csv_with_empty_values() {
|
||||
let (path, _dir) = create_temp_csv("Region,Product,Revenue\nEast,,1000\nWest,Shirts,");
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
assert_eq!(records.len(), 2);
|
||||
assert_eq!(records[0]["Product"], Value::Null);
|
||||
assert_eq!(records[1]["Revenue"], Value::Null);
|
||||
}
|
||||
|
||||
#[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 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!(records[0]["Price"].is_f64());
|
||||
assert_eq!(records[0]["Active"], Value::String("true".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_checking_csv_format() {
|
||||
// Simulates the format of /Users/edwlan/Downloads/Checking1.csv
|
||||
let (path, _dir) = create_temp_csv(
|
||||
"Date,Amount,Flag,CheckNo,Description\n\
|
||||
\"03/31/2026\",\"-50.00\",\"*\",\"\",\"VENMO PAYMENT 260331\"\n\
|
||||
\"03/31/2026\",\"-240.00\",\"*\",\"\",\"ROBINHOOD DEBITS XXXXX3795\"",
|
||||
);
|
||||
let records = parse_csv(&path).unwrap();
|
||||
|
||||
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]["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()));
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod analyzer;
|
||||
pub mod csv_parser;
|
||||
pub mod wizard;
|
||||
|
||||
462
src/main.rs
462
src/main.rs
@ -54,26 +54,40 @@ impl Runnable for CmdLineArgs {
|
||||
// Load or create model
|
||||
let model = get_initial_model(&self.file_path)?;
|
||||
|
||||
// Pre-TUI import: parse JSON and open wizard
|
||||
let import_json = if let Some(ref path) = self.import_path {
|
||||
// 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());
|
||||
return Ok(());
|
||||
}
|
||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Err(e) => {
|
||||
eprintln!("JSON parse error: {e}");
|
||||
return Ok(());
|
||||
Ok(content) => {
|
||||
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}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parse JSON
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Err(e) => {
|
||||
eprintln!("JSON parse error: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(json) => Some(json),
|
||||
}
|
||||
}
|
||||
Ok(json) => Some(json),
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
run_tui(model, self.file_path, import_json)
|
||||
run_tui(model, self.file_path, import_value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,7 +144,7 @@ impl Runnable for HelpArgs {
|
||||
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 then open TUI");
|
||||
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):");
|
||||
@ -246,13 +260,13 @@ impl<'a> Drop for TuiContext<'a> {
|
||||
fn run_tui(
|
||||
model: Model,
|
||||
file_path: Option<PathBuf>,
|
||||
import_json: Option<serde_json::Value>,
|
||||
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_json {
|
||||
if let Some(json) = import_value {
|
||||
app.start_import_wizard(json);
|
||||
}
|
||||
|
||||
@ -277,165 +291,29 @@ fn run_tui(
|
||||
|
||||
// ── Drawing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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, app);
|
||||
}
|
||||
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
|
||||
draw_welcome(f, main_chunks[1]);
|
||||
}
|
||||
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 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 pad = " ".repeat((area.width as usize).saturating_sub(title.len() + right.len()));
|
||||
let line = format!("{title}{pad}{right}");
|
||||
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) {
|
||||
if app.formula_panel_open || app.category_panel_open || app.view_panel_open {
|
||||
let side_w = 32u16;
|
||||
let chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(40), Constraint::Length(side_w)]).split(area);
|
||||
f.render_widget(GridWidget::new(&app.model, &app.mode, &app.search_query), chunks[0]);
|
||||
|
||||
let side = chunks[1];
|
||||
let open_panels = [app.formula_panel_open, app.category_panel_open, app.view_panel_open];
|
||||
let panel_count = open_panels.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 {
|
||||
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), Rect::new(side.x, y, side.width, ph));
|
||||
y += ph;
|
||||
}
|
||||
if app.category_panel_open {
|
||||
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), Rect::new(side.x, y, side.width, ph));
|
||||
y += ph;
|
||||
}
|
||||
if app.view_panel_open {
|
||||
f.render_widget(ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), Rect::new(side.x, y, side.width, ph));
|
||||
}
|
||||
} else {
|
||||
f.render_widget(GridWidget::new(&app.model, &app.mode, &app.search_query), 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 mode_badge = mode_name(&app.mode);
|
||||
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!(" {mode_badge}{search_part} {msg}");
|
||||
let right = view_badge;
|
||||
let pad = " ".repeat((area.width as usize).saturating_sub(left.len() + right.len()));
|
||||
let line = format!("{left}{pad}{right}");
|
||||
let badge_style = mode_style(&app.mode);
|
||||
f.render_widget(Paragraph::new(line).style(badge_style), 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, app: &App) {
|
||||
let area = f.area();
|
||||
let popup_w = 64u16.min(area.width);
|
||||
let x = area.x + area.width.saturating_sub(popup_w) / 2;
|
||||
let y = area.y + area.height / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, 3);
|
||||
|
||||
f.render_widget(Clear, popup_area);
|
||||
let block = Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow)).title(" Export CSV — path (Esc cancel) ");
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { "" };
|
||||
f.render_widget(Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)), inner);
|
||||
}
|
||||
|
||||
fn draw_welcome(f: &mut Frame, area: Rect) {
|
||||
let w = 58u16.min(area.width.saturating_sub(4));
|
||||
let h = 20u16.min(area.height.saturating_sub(2));
|
||||
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;
|
||||
let popup = Rect::new(x, y, w, h);
|
||||
|
||||
f.render_widget(Clear, popup);
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Blue)).title(" Welcome to improvise ");
|
||||
let inner = block.inner(popup);
|
||||
f.render_widget(block, popup);
|
||||
|
||||
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.json> Import a JSON 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));
|
||||
}
|
||||
Rect::new(x, y, w, h)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
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 {
|
||||
@ -465,4 +343,258 @@ fn mode_style(mode: &AppMode) -> Style {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user