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 = std::env::args().collect(); let arg_config = parse_args(args); arg_config.run() } trait Runnable { fn run(self: Box) -> Result<()>; } struct CmdLineArgs { file_path: Option, import_path: Option, } impl Runnable for CmdLineArgs { fn run(self: Box) -> 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::(&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, commands: Vec, script: Option, } impl Runnable for HeadlessArgs { fn run(self: Box) -> Result<()> { let mut model = get_initial_model(&self.file_path)?; let mut cmds: Vec = 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) -> 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) -> Box { let mut file_path: Option = None; let mut headless_cmds: Vec = Vec::new(); let mut headless_script: Option = None; let mut import_path: Option = 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) -> Result { 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>, } impl<'a> TuiContext<'a> { fn enter(out: &'a mut Stdout) -> Result { 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, import_json: Option, ) -> 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 Import a JSON file", Style::default().fg(Color::Cyan)), (":add-cat Add a category (dimension)", Style::default().fg(Color::Cyan)), (":add-item Add an item to a category", Style::default().fg(Color::Cyan)), (":formula Add a formula, e.g.:", Style::default().fg(Color::Cyan)), (" Profit = Revenue - Cost", Style::default().fg(Color::Green)), (":w 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), } }