15 Commits

Author SHA1 Message Date
ef79a39721 Add CSV import functionality
- Use csv crate for robust CSV parsing (handles quoted fields, empty values, \r\n)
- Extend --import command to auto-detect format by file extension (.csv or .json)
- Reuse existing ImportPipeline and analyzer for field type detection
- Categories detected automatically (string fields), measures for numeric fields
- Updated help text and welcome screen to mention CSV support

All 201 tests pass.
2026-04-01 01:32:19 -07:00
9fc3f0b5d6 refactor: synthesize previous refactors 2026-04-01 01:01:19 -07:00
3f84ba03cb Revert "refactor: mystery model 3"
This reverts commit 4b721f7543.
2026-04-01 00:46:55 -07:00
4b721f7543 refactor: mystery model 3 2026-04-01 00:46:25 -07:00
6d88de3020 Revert "refactor: mystery model #2"
This reverts commit 87fd6a1620.
2026-04-01 00:41:25 -07:00
87fd6a1620 refactor: mystery model #2 2026-04-01 00:40:22 -07:00
a57d3ed294 Revert "refactor: mystery model #1"
This reverts commit bbebc3344c.
2026-04-01 00:32:12 -07:00
bbebc3344c refactor: mystery model #1 2026-04-01 00:32:07 -07:00
ff08e3c2c2 chore: Revert refactors to give claude a clean slate 2026-04-01 00:26:55 -07:00
8c84256ebc refactor: merge using claude sonnet 2026-04-01 00:25:19 -07:00
d915908354 refactor: unsloth/Qwen3-Coder-Next-GGUF:Q5_K_M refactors the drawing helper 2026-04-01 00:20:19 -07:00
7731c7ceab Revert "refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M"
This reverts commit 98d151f345.
2026-03-31 23:11:21 -07:00
98d151f345 refactor: unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M 2026-03-31 23:10:52 -07:00
f1e6e61bca Revert "test: use gpt-oss-20b to do some minor refactoring"
This reverts commit bbd1f48b78.
2026-03-31 22:50:10 -07:00
bbd1f48b78 test: use gpt-oss-20b to do some minor refactoring 2026-03-31 22:50:07 -07:00
7 changed files with 13126 additions and 121 deletions

23
Cargo.lock generated
View File

@ -161,6 +161,27 @@ dependencies = [
"winapi", "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]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@ -374,6 +395,7 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"crossterm", "crossterm",
"csv",
"dirs", "dirs",
"flate2", "flate2",
"indexmap", "indexmap",
@ -381,6 +403,7 @@ dependencies = [
"ratatui", "ratatui",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror", "thiserror",
"unicode-width 0.2.0", "unicode-width 0.2.0",
] ]

View File

@ -21,9 +21,11 @@ chrono = { version = "0.4", features = ["serde"] }
flate2 = "1" flate2 = "1"
unicode-width = "0.2" unicode-width = "0.2"
dirs = "5" dirs = "5"
csv = "1"
[dev-dependencies] [dev-dependencies]
proptest = "1" proptest = "1"
tempfile = "3"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3

12789
llama-server.log Normal file

File diff suppressed because it is too large Load Diff

View File

@ -163,50 +163,69 @@ pub fn dispatch(model: &mut Model, cmd: &Command) -> CommandResult {
path, path,
model_name, model_name,
array_path, 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, model: &mut Model,
path: &str, path: &str,
model_name: Option<&str>, model_name: Option<&str>,
array_path: Option<&str>, array_path: Option<&str>,
) -> CommandResult { ) -> CommandResult {
let content = match std::fs::read_to_string(path) { let is_csv = path.ends_with(".csv");
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 records = if let Some(ap) = array_path.filter(|s| !s.is_empty()) { let records = if is_csv {
match extract_array_at_path(&value, ap) { // Parse CSV file
Some(arr) => arr.clone(), match crate::import::csv_parser::parse_csv(path) {
None => return CommandResult::err(format!("No array at path '{ap}'")), Ok(recs) => recs,
Err(e) => return CommandResult::err(e.to_string()),
} }
} else if let Some(arr) = value.as_array() {
arr.clone()
} else { } else {
// Find first array // Parse JSON file
let paths = crate::import::analyzer::find_array_paths(&value); let content = match std::fs::read_to_string(path) {
if let Some(first) = paths.first() { Ok(c) => c,
match extract_array_at_path(&value, first) { 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(), 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 { } 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); 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 { let pipeline = crate::import::wizard::ImportPipeline {
raw: value, raw,
array_paths: vec![], array_paths: vec![],
selected_path: array_path.unwrap_or("").to_string(), selected_path: array_path.unwrap_or("").to_string(),
records, records,
@ -223,7 +242,7 @@ fn import_json_headless(
match pipeline.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("Imported successfully")
} }
Err(e) => CommandResult::err(e.to_string()), Err(e) => CommandResult::err(e.to_string()),
} }

159
src/import/csv_parser.rs Normal file
View 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()));
}
}

View File

@ -1,2 +1,3 @@
pub mod analyzer; pub mod analyzer;
pub mod csv_parser;
pub mod wizard; pub mod wizard;

View File

@ -54,26 +54,40 @@ impl Runnable for CmdLineArgs {
// Load or create model // Load or create model
let model = get_initial_model(&self.file_path)?; let model = get_initial_model(&self.file_path)?;
// Pre-TUI import: parse JSON and open wizard // Pre-TUI import: parse JSON or CSV and open wizard
let import_json = if let Some(ref path) = self.import_path { let import_value = if let Some(ref path) = self.import_path {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Err(e) => { Err(e) => {
eprintln!("Cannot read '{}': {e}", path.display()); eprintln!("Cannot read '{}': {e}", path.display());
return Ok(()); return Ok(());
} }
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) { Ok(content) => {
Err(e) => { if path.to_string_lossy().ends_with(".csv") {
eprintln!("JSON parse error: {e}"); // Parse CSV and wrap as JSON array
return Ok(()); 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 { } else {
None 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!("improvise — multi-dimensional data modeling TUI\n");
println!("USAGE:"); println!("USAGE:");
println!(" improvise [file.improv] Open or create a model"); 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 --cmd '{{...}}' Run a JSON command (headless, repeatable)");
println!(" improvise --script cmds.jsonl Run commands from file (headless)"); println!(" improvise --script cmds.jsonl Run commands from file (headless)");
println!("\nTUI KEYS (vim-style):"); println!("\nTUI KEYS (vim-style):");
@ -246,13 +260,13 @@ impl<'a> Drop for TuiContext<'a> {
fn run_tui( fn run_tui(
model: Model, model: Model,
file_path: Option<PathBuf>, file_path: Option<PathBuf>,
import_json: Option<serde_json::Value>, import_value: Option<serde_json::Value>,
) -> Result<()> { ) -> Result<()> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let mut tui_context = TuiContext::enter(&mut stdout)?; let mut tui_context = TuiContext::enter(&mut stdout)?;
let mut app = App::new(model, file_path); 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); app.start_import_wizard(json);
} }
@ -277,11 +291,61 @@ fn run_tui(
// ── Drawing ────────────────────────────────────────────────────────────────── // ── Drawing ──────────────────────────────────────────────────────────────────
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 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;
Rect::new(x, y, w, h)
}
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 {
AppMode::Normal => "NORMAL",
AppMode::Editing { .. } => "INSERT",
AppMode::FormulaEdit { .. } => "FORMULA",
AppMode::FormulaPanel => "FORMULAS",
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
}
}
fn mode_style(mode: &AppMode) -> Style {
match mode {
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
}
}
fn draw(f: &mut Frame, app: &App) { fn draw(f: &mut Frame, app: &App) {
let size = f.area(); let size = f.area();
let is_cmd_mode = matches!(app.mode, AppMode::CommandMode { .. });
let main_chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@ -295,12 +359,7 @@ fn draw(f: &mut Frame, app: &App) {
draw_title(f, main_chunks[0], app); draw_title(f, main_chunks[0], app);
draw_content(f, main_chunks[1], app); draw_content(f, main_chunks[1], app);
draw_tile_bar(f, main_chunks[2], app); draw_tile_bar(f, main_chunks[2], app);
draw_bottom_bar(f, main_chunks[3], app);
if is_cmd_mode {
draw_command_bar(f, main_chunks[3], app);
} else {
draw_status(f, main_chunks[3], app);
}
// Overlays (rendered last so they appear on top) // Overlays (rendered last so they appear on top)
if matches!(app.mode, AppMode::Help) { if matches!(app.mode, AppMode::Help) {
@ -315,7 +374,7 @@ fn draw(f: &mut Frame, app: &App) {
draw_export_prompt(f, size, app); draw_export_prompt(f, size, app);
} }
if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) { if app.is_empty_model() && matches!(app.mode, AppMode::Normal | AppMode::CommandMode { .. }) {
draw_welcome(f, main_chunks[1], app); draw_welcome(f, main_chunks[1]);
} }
} }
@ -330,8 +389,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
.unwrap_or_default(); .unwrap_or_default();
let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty); let title = format!(" improvise · {}{}{} ", app.model.name, file, dirty);
let right = " ?:help :q quit "; let right = " ?:help :q quit ";
let pad = " ".repeat((area.width as usize).saturating_sub(title.len() + right.len())); let line = fill_line(title, right, area.width);
let line = format!("{title}{pad}{right}");
f.render_widget( f.render_widget(
Paragraph::new(line).style( Paragraph::new(line).style(
Style::default() Style::default()
@ -346,6 +404,7 @@ fn draw_title(f: &mut Frame, area: Rect, app: &App) {
fn draw_content(f: &mut Frame, area: Rect, app: &App) { 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 side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
let grid_area;
if side_open { if side_open {
let side_w = 32u16; let side_w = 32u16;
let chunks = Layout::default() let chunks = Layout::default()
@ -353,10 +412,7 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
.constraints([Constraint::Min(40), Constraint::Length(side_w)]) .constraints([Constraint::Min(40), Constraint::Length(side_w)])
.split(area); .split(area);
f.render_widget( grid_area = chunks[0];
GridWidget::new(&app.model, &app.mode, &app.search_query),
chunks[0],
);
let side = chunks[1]; let side = chunks[1];
let panel_count = [ let panel_count = [
@ -394,35 +450,27 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
); );
} }
} else { } else {
f.render_widget( grid_area = area;
GridWidget::new(&app.model, &app.mode, &app.search_query),
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) { fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(TileBar::new(&app.model, &app.mode), area); f.render_widget(TileBar::new(&app.model, &app.mode), area);
} }
fn draw_status(f: &mut Frame, area: Rect, app: &App) { fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
let mode_badge = match &app.mode { match app.mode {
AppMode::Normal => "NORMAL", AppMode::CommandMode { ref buffer } => draw_command_bar(f, area, buffer),
AppMode::Editing { .. } => "INSERT", _ => draw_status(f, area, app),
AppMode::FormulaEdit { .. } => "FORMULA", }
AppMode::FormulaPanel => "FORMULAS", }
AppMode::CategoryPanel => "CATEGORIES",
AppMode::CategoryAdd { .. } => "NEW CATEGORY",
AppMode::ItemAdd { .. } => "ADD ITEMS",
AppMode::ViewPanel => "VIEWS",
AppMode::TileSelect { .. } => "TILES",
AppMode::ImportWizard => "IMPORT",
AppMode::ExportPrompt { .. } => "EXPORT",
AppMode::CommandMode { .. } => "COMMAND",
AppMode::Help => "HELP",
AppMode::Quit => "QUIT",
};
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let search_part = if app.search_mode { let search_part = if app.search_mode {
format!(" /{}", app.search_query) format!(" /{}", app.search_query)
} else { } else {
@ -438,30 +486,16 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" }; let yank_indicator = if app.yanked.is_some() { " [yank]" } else { "" };
let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator); let view_badge = format!(" {}{} ", app.model.active_view, yank_indicator);
let left = format!(" {mode_badge}{search_part} {msg}"); let left = format!(" {}{search_part} {msg}", mode_name(&app.mode));
let right = view_badge; let line = fill_line(left, &view_badge, area.width);
let pad = " ".repeat((area.width as usize).saturating_sub(left.len() + right.len()));
let line = format!("{left}{pad}{right}");
let badge_style = match &app.mode { f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), area);
AppMode::Editing { .. } => Style::default().fg(Color::Black).bg(Color::Green),
AppMode::CommandMode { .. } => Style::default().fg(Color::Black).bg(Color::Yellow),
AppMode::TileSelect { .. } => Style::default().fg(Color::Black).bg(Color::Magenta),
_ => Style::default().fg(Color::Black).bg(Color::DarkGray),
};
f.render_widget(Paragraph::new(line).style(badge_style), area);
} }
fn draw_command_bar(f: &mut Frame, area: Rect, app: &App) { fn draw_command_bar(f: &mut Frame, area: Rect, buffer: &str) {
let buf = if let AppMode::CommandMode { buffer } = &app.mode {
buffer.as_str()
} else {
""
};
let line = format!(":{buf}");
f.render_widget( f.render_widget(
Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Black)), Paragraph::new(format!(":{buffer}"))
.style(Style::default().fg(Color::White).bg(Color::Black)),
area, area,
); );
} }
@ -472,39 +506,17 @@ fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
} else { } else {
"" ""
}; };
let popup_w = 64u16.min(area.width); let popup = centered_popup(area, 64, 3);
let x = area.x + area.width.saturating_sub(popup_w) / 2; let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow);
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);
f.render_widget( f.render_widget(
Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)), Paragraph::new(format!("{buf}")).style(Style::default().fg(Color::Green)),
inner, inner,
); );
} }
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) { fn draw_welcome(f: &mut Frame, area: Rect) {
let w = 58u16.min(area.width.saturating_sub(4)); let popup = centered_popup(area, 58, 20);
let h = 20u16.min(area.height.saturating_sub(2)); let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue);
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)] = &[ let lines: &[(&str, Style)] = &[
( (
@ -520,7 +532,7 @@ fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
), ),
("", Style::default()), ("", Style::default()),
( (
":import <file.json> Import a JSON file", ":import <file> Import JSON or CSV file",
Style::default().fg(Color::Cyan), Style::default().fg(Color::Cyan),
), ),
( (