Previously --import ran ImportJson headless before the TUI started, hitting the category limit and printing the error to stderr where it was invisible. Now it parses the JSON and opens the ImportWizard on startup, matching :import behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
16 KiB
Rust
446 lines
16 KiB
Rust
mod model;
|
|
mod formula;
|
|
mod view;
|
|
mod ui;
|
|
mod import;
|
|
mod persistence;
|
|
mod command;
|
|
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use crossterm::{
|
|
event::{self, Event},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{
|
|
backend::CrosstermBackend,
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
widgets::{Block, Borders, Clear, Paragraph},
|
|
Frame, Terminal,
|
|
};
|
|
|
|
use ui::app::{App, AppMode};
|
|
use ui::grid::GridWidget;
|
|
use ui::tile_bar::TileBar;
|
|
use ui::formula_panel::FormulaPanel;
|
|
use ui::category_panel::CategoryPanel;
|
|
use ui::view_panel::ViewPanel;
|
|
use ui::help::HelpWidget;
|
|
use ui::import_wizard_ui::ImportWizardWidget;
|
|
use model::Model;
|
|
|
|
fn main() -> Result<()> {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let mut file_path: Option<PathBuf> = None;
|
|
let mut headless_cmds: Vec<String> = Vec::new();
|
|
let mut headless_script: Option<PathBuf> = None;
|
|
let mut import_path: Option<PathBuf> = None;
|
|
|
|
let mut i = 1;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--cmd" | "-c" => {
|
|
i += 1;
|
|
if let Some(cmd) = args.get(i).cloned() {
|
|
headless_cmds.push(cmd);
|
|
}
|
|
}
|
|
"--script" | "-s" => {
|
|
i += 1;
|
|
headless_script = args.get(i).map(PathBuf::from);
|
|
}
|
|
"--import" => {
|
|
i += 1;
|
|
import_path = args.get(i).map(PathBuf::from);
|
|
}
|
|
"--help" | "-h" => {
|
|
print_usage();
|
|
return Ok(());
|
|
}
|
|
arg if !arg.starts_with('-') => {
|
|
file_path = Some(PathBuf::from(arg));
|
|
}
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
// Load or create model
|
|
let mut model = if let Some(ref path) = file_path {
|
|
if path.exists() {
|
|
let mut m = persistence::load(path)
|
|
.with_context(|| format!("Failed to load {}", path.display()))?;
|
|
m.normalize_view_state();
|
|
m
|
|
} else {
|
|
let name = path.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("New Model")
|
|
.to_string();
|
|
Model::new(name)
|
|
}
|
|
} else {
|
|
Model::new("New Model")
|
|
};
|
|
|
|
// Headless mode
|
|
if !headless_cmds.is_empty() || headless_script.is_some() {
|
|
return run_headless(&mut model, file_path, headless_cmds, headless_script);
|
|
}
|
|
|
|
// Pre-TUI import: parse JSON and open wizard
|
|
let import_json = if let Some(ref path) = 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(json) => Some(json),
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
run_tui(model, file_path, import_json)
|
|
}
|
|
|
|
fn run_headless(
|
|
model: &mut Model,
|
|
file_path: Option<PathBuf>,
|
|
inline_cmds: Vec<String>,
|
|
script: Option<PathBuf>,
|
|
) -> Result<()> {
|
|
let mut cmds: Vec<String> = inline_cmds;
|
|
if let Some(script_path) = script {
|
|
let content = std::fs::read_to_string(&script_path)?;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with('#') {
|
|
cmds.push(trimmed.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut exit_code = 0;
|
|
for raw_cmd in &cmds {
|
|
let parsed: command::Command = match serde_json::from_str(raw_cmd) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let r = command::CommandResult::err(format!("JSON parse error: {e}"));
|
|
println!("{}", serde_json::to_string(&r)?);
|
|
exit_code = 1;
|
|
continue;
|
|
}
|
|
};
|
|
let result = command::dispatch(model, &parsed);
|
|
if !result.ok { exit_code = 1; }
|
|
println!("{}", serde_json::to_string(&result)?);
|
|
}
|
|
|
|
if let Some(path) = file_path {
|
|
persistence::save(model, &path)?;
|
|
}
|
|
|
|
std::process::exit(exit_code);
|
|
}
|
|
|
|
fn run_tui(model: Model, file_path: Option<PathBuf>, import_json: Option<serde_json::Value>) -> Result<()> {
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
let mut app = App::new(model, file_path);
|
|
if let Some(json) = import_json {
|
|
app.start_import_wizard(json);
|
|
}
|
|
|
|
loop {
|
|
terminal.draw(|f| draw(f, &app))?;
|
|
|
|
if event::poll(Duration::from_millis(100))? {
|
|
if let Event::Key(key) = event::read()? {
|
|
app.handle_key(key)?;
|
|
}
|
|
}
|
|
|
|
app.autosave_if_needed();
|
|
|
|
if matches!(app.mode, AppMode::Quit) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
disable_raw_mode()?;
|
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Drawing ──────────────────────────────────────────────────────────────────
|
|
|
|
fn draw(f: &mut Frame, app: &App) {
|
|
let size = f.area();
|
|
|
|
let is_cmd_mode = matches!(app.mode, AppMode::CommandMode { .. });
|
|
|
|
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);
|
|
|
|
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)
|
|
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], app);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let side_open = app.formula_panel_open || app.category_panel_open || app.view_panel_open;
|
|
|
|
if side_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 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 {
|
|
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_status(f: &mut Frame, area: Rect, app: &App) {
|
|
let mode_badge = match &app.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",
|
|
};
|
|
|
|
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 = match &app.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),
|
|
};
|
|
|
|
f.render_widget(Paragraph::new(line).style(badge_style), area);
|
|
}
|
|
|
|
fn draw_command_bar(f: &mut Frame, area: Rect, app: &App) {
|
|
let buf = if let AppMode::CommandMode { buffer } = &app.mode { buffer.as_str() } else { "" };
|
|
let line = format!(":{buf}▌");
|
|
f.render_widget(
|
|
Paragraph::new(line).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_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);
|
|
f.render_widget(
|
|
Paragraph::new(format!("{buf}▌"))
|
|
.style(Style::default().fg(Color::Green)),
|
|
inner,
|
|
);
|
|
}
|
|
|
|
fn draw_welcome(f: &mut Frame, area: Rect, _app: &App) {
|
|
let w = 58u16.min(area.width.saturating_sub(4));
|
|
let h = 20u16.min(area.height.saturating_sub(2));
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Help text ─────────────────────────────────────────────────────────────────
|
|
|
|
// (HelpWidget is in src/ui/help.rs — updated separately)
|
|
|
|
// ── Usage ─────────────────────────────────────────────────────────────────────
|
|
|
|
fn print_usage() {
|
|
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 --cmd '{{...}}' Run a JSON command (headless, repeatable)");
|
|
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
|
|
println!("\nTUI KEYS (vim-style):");
|
|
println!(" : Command mode (:q :w :import :add-cat :formula …)");
|
|
println!(" hjkl / ↑↓←→ Navigate grid");
|
|
println!(" i / Enter Edit cell (Insert mode)");
|
|
println!(" Esc Return to Normal mode");
|
|
println!(" x Clear cell");
|
|
println!(" yy / p Yank / paste cell value");
|
|
println!(" gg / G First / last row");
|
|
println!(" 0 / $ First / last column");
|
|
println!(" Ctrl+D/U Scroll half-page down / up");
|
|
println!(" / n N Search / next / prev");
|
|
println!(" [ ] Cycle page-axis filter");
|
|
println!(" T Tile-select (pivot) mode");
|
|
println!(" F C V Toggle Formulas / Categories / Views panel");
|
|
println!(" ZZ Save and quit");
|
|
println!(" ? Help");
|
|
}
|