Files
improvise/src/main.rs

469 lines
17 KiB
Rust

mod command;
mod formula;
mod import;
mod model;
mod persistence;
mod ui;
mod view;
use std::io::{self, Stdout};
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 model::Model;
use ui::app::{App, AppMode};
use ui::category_panel::CategoryPanel;
use ui::formula_panel::FormulaPanel;
use ui::grid::GridWidget;
use ui::help::HelpWidget;
use ui::import_wizard_ui::ImportWizardWidget;
use ui::tile_bar::TileBar;
use ui::view_panel::ViewPanel;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let arg_config = parse_args(args);
arg_config.run()
}
trait Runnable {
fn run(self: Box<Self>) -> Result<()>;
}
struct CmdLineArgs {
file_path: Option<PathBuf>,
import_path: Option<PathBuf>,
}
impl Runnable for CmdLineArgs {
fn run(self: Box<Self>) -> Result<()> {
// 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 {
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, self.file_path, import_json)
}
}
struct HeadlessArgs {
file_path: Option<PathBuf>,
commands: Vec<String>,
script: Option<PathBuf>,
}
impl Runnable for HeadlessArgs {
fn run(self: Box<Self>) -> Result<()> {
let mut model = get_initial_model(&self.file_path)?;
let mut cmds: Vec<String> = self.commands;
if let Some(script_path) = self.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(&mut model, &parsed);
if !result.ok {
exit_code = 1;
}
println!("{}", serde_json::to_string(&result)?);
}
if let Some(path) = self.file_path {
persistence::save(&mut model, &path)?;
}
std::process::exit(exit_code);
}
}
struct HelpArgs;
impl Runnable for HelpArgs {
fn run(self: Box<Self>) -> Result<()> {
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");
Ok(())
}
}
fn parse_args(args: Vec<String>) -> Box<dyn Runnable> {
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" => {
return Box::new(HelpArgs);
}
arg if !arg.starts_with('-') => {
file_path = Some(PathBuf::from(arg));
}
_ => {}
}
i += 1;
}
if !headless_cmds.is_empty() || headless_script.is_some() {
Box::new(HeadlessArgs {
file_path,
commands: headless_cmds,
script: headless_script,
})
} else {
Box::new(CmdLineArgs {
file_path,
import_path,
})
}
}
fn get_initial_model(file_path: &Option<PathBuf>) -> Result<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();
Ok(m)
} else {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("New Model")
.to_string();
Ok(Model::new(name))
}
} else {
Ok(Model::new("New Model"))
}
}
struct TuiContext<'a> {
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
}
impl<'a> TuiContext<'a> {
fn enter(out: &'a mut Stdout) -> Result<Self> {
enable_raw_mode()?;
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl<'a> Drop for TuiContext<'a> {
fn drop(&mut self) {
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
fn run_tui(
model: Model,
file_path: Option<PathBuf>,
import_json: 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 {
app.start_import_wizard(json);
}
loop {
tui_context.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;
}
}
Ok(())
}
// ── 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 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));
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));
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
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),
}
}