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 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 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, ), ); } }