Initial implementation of Improvise TUI
Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
380
src/main.rs
Normal file
380
src/main.rs
Normal file
@ -0,0 +1,380 @@
|
||||
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, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, 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() {
|
||||
persistence::load(path).with_context(|| format!("Failed to load {}", path.display()))?
|
||||
} 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: run command(s) and print results
|
||||
if !headless_cmds.is_empty() || headless_script.is_some() {
|
||||
return run_headless(&mut model, file_path, headless_cmds, headless_script);
|
||||
}
|
||||
|
||||
// Import mode before TUI
|
||||
if let Some(ref path) = import_path {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let json: serde_json::Value = serde_json::from_str(&content)?;
|
||||
let cmd = command::Command::ImportJson {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
model_name: None,
|
||||
array_path: None,
|
||||
};
|
||||
let result = command::dispatch(&mut model, &cmd);
|
||||
if !result.ok {
|
||||
eprintln!("Import error: {}", result.message.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
// TUI mode
|
||||
run_tui(model, file_path)
|
||||
}
|
||||
|
||||
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 result = command::CommandResult::err(format!("JSON parse error: {e}"));
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
exit_code = 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let result = command::dispatch(model, &parsed);
|
||||
if !result.ok { exit_code = 1; }
|
||||
println!("{}", serde_json::to_string(&result)?);
|
||||
}
|
||||
|
||||
// Auto-save if we have a file path and model was potentially modified
|
||||
if let Some(path) = file_path {
|
||||
persistence::save(model, &path)?;
|
||||
}
|
||||
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
fn run_tui(model: Model, file_path: Option<PathBuf>) -> 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);
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| draw(f, &app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(200))? {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn draw(f: &mut Frame, app: &App) {
|
||||
let size = f.area();
|
||||
|
||||
// Main layout: title bar + content + status bar
|
||||
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 bar
|
||||
])
|
||||
.split(size);
|
||||
|
||||
// Title bar
|
||||
draw_title(f, main_chunks[0], app);
|
||||
|
||||
// Content area: grid + optional panels
|
||||
let content_area = main_chunks[1];
|
||||
draw_content(f, content_area, app);
|
||||
|
||||
// Tile bar
|
||||
draw_tile_bar(f, main_chunks[2], app);
|
||||
|
||||
// Status bar
|
||||
draw_status(f, main_chunks[3], app);
|
||||
|
||||
// Overlays
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_title(f: &mut Frame, area: Rect, app: &App) {
|
||||
let dirty = if app.dirty { " [*]" } else { "" };
|
||||
let title = format!(" Improvise | Model: {}{} ", app.model.name, dirty);
|
||||
let help_hint = " [F1 Help] [Ctrl+Q Quit] ";
|
||||
let padding = " ".repeat(
|
||||
(area.width as usize).saturating_sub(title.len() + help_hint.len())
|
||||
);
|
||||
let full = format!("{title}{padding}{help_hint}");
|
||||
let style = Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD);
|
||||
f.render_widget(Paragraph::new(full).style(style), area);
|
||||
}
|
||||
|
||||
fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
let has_formula = app.formula_panel_open;
|
||||
let has_category = app.category_panel_open;
|
||||
let has_view = app.view_panel_open;
|
||||
let side_open = has_formula || has_category || has_view;
|
||||
|
||||
if side_open {
|
||||
let side_width = 30u16;
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Min(40),
|
||||
Constraint::Length(side_width),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Grid
|
||||
f.render_widget(
|
||||
GridWidget::new(&app.model, &app.mode, &app.search_query),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Side panels stacked
|
||||
let side_area = chunks[1];
|
||||
let panel_count = [has_formula, has_category, has_view].iter().filter(|&&b| b).count();
|
||||
let panel_height = side_area.height / panel_count.max(1) as u16;
|
||||
|
||||
let mut y = side_area.y;
|
||||
if has_formula {
|
||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
||||
f.render_widget(FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), a);
|
||||
y += panel_height;
|
||||
}
|
||||
if has_category {
|
||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
||||
f.render_widget(CategoryPanel::new(&app.model, &app.mode, app.cat_panel_cursor), a);
|
||||
y += panel_height;
|
||||
}
|
||||
if has_view {
|
||||
let a = Rect::new(side_area.x, y, side_area.width, panel_height);
|
||||
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_str = match &app.mode {
|
||||
AppMode::Normal => "NORMAL",
|
||||
AppMode::Editing { .. } => "EDIT",
|
||||
AppMode::FormulaEdit { .. } => "FORMULA",
|
||||
AppMode::FormulaPanel => "FORMULA PANEL",
|
||||
AppMode::CategoryPanel => "CATEGORY PANEL",
|
||||
AppMode::ViewPanel => "VIEW PANEL",
|
||||
AppMode::TileSelect { .. } => "TILE SELECT",
|
||||
AppMode::ImportWizard => "IMPORT",
|
||||
AppMode::ExportPrompt { .. } => "EXPORT",
|
||||
AppMode::Help => "HELP",
|
||||
AppMode::Quit => "QUIT",
|
||||
};
|
||||
|
||||
let search_part = if app.search_mode {
|
||||
format!(" [Search: {}]", app.search_query)
|
||||
} else { String::new() };
|
||||
|
||||
let panels = format!(
|
||||
"{}{}{}",
|
||||
if app.formula_panel_open { " [F]" } else { "" },
|
||||
if app.category_panel_open { " [C]" } else { "" },
|
||||
if app.view_panel_open { " [V]" } else { "" },
|
||||
);
|
||||
|
||||
let status = format!(
|
||||
" {mode_str}{search_part}{panels} | {} | {}",
|
||||
app.model.active_view,
|
||||
if app.status_msg.is_empty() {
|
||||
"Ctrl+F:formulas Ctrl+C:categories Ctrl+V:views Ctrl+S:save".to_string()
|
||||
} else {
|
||||
app.status_msg.clone()
|
||||
}
|
||||
);
|
||||
|
||||
let style = Style::default().fg(Color::Black).bg(Color::DarkGray);
|
||||
f.render_widget(
|
||||
Paragraph::new(status).style(style),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_export_prompt(f: &mut Frame, area: Rect, app: &App) {
|
||||
let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer } else { return };
|
||||
let popup_w = 60u16.min(area.width);
|
||||
let popup_h = 3u16;
|
||||
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, popup_h);
|
||||
|
||||
use ratatui::widgets::Clear;
|
||||
f.render_widget(Clear, popup_area);
|
||||
let block = Block::default().borders(Borders::ALL).title(" Export CSV — enter 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 print_usage() {
|
||||
println!("improvise — multi-dimensional data modeling TUI");
|
||||
println!();
|
||||
println!("USAGE:");
|
||||
println!(" improvise [file.improv] Open or create a model file");
|
||||
println!(" improvise --import data.json Import JSON, then open TUI");
|
||||
println!(" improvise --cmd '{{...}}' Run a single JSON command (headless)");
|
||||
println!(" improvise --script cmds.jsonl Run commands from file (headless)");
|
||||
println!();
|
||||
println!("HEADLESS COMMANDS (JSON object with 'op' field):");
|
||||
println!(" {{\"op\":\"AddCategory\",\"name\":\"Region\"}}");
|
||||
println!(" {{\"op\":\"AddItem\",\"category\":\"Region\",\"item\":\"East\"}}");
|
||||
println!(" {{\"op\":\"SetCell\",\"coords\":[[\"Region\",\"East\"],[\"Measure\",\"Revenue\"]],\"number\":1200}}");
|
||||
println!(" {{\"op\":\"AddFormula\",\"raw\":\"Profit = Revenue - Cost\",\"target_category\":\"Measure\"}}");
|
||||
println!(" {{\"op\":\"Save\",\"path\":\"model.improv\"}}");
|
||||
println!(" {{\"op\":\"ImportJson\",\"path\":\"data.json\"}}");
|
||||
println!();
|
||||
println!("TUI SHORTCUTS:");
|
||||
println!(" F1 Help");
|
||||
println!(" Ctrl+Q Quit");
|
||||
println!(" Ctrl+S Save");
|
||||
println!(" Ctrl+F Formula panel");
|
||||
println!(" Ctrl+C Category panel");
|
||||
println!(" Ctrl+V View panel");
|
||||
println!(" Enter Edit cell");
|
||||
println!(" Ctrl+Arrow Tile select mode");
|
||||
println!(" [ / ] Prev/next page item");
|
||||
}
|
||||
Reference in New Issue
Block a user