use std::io::{self, Stdout}; use std::path::PathBuf; use std::time::Duration; use anyhow::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 crate::model::Model; use crate::ui::app::{App, AppMode}; use crate::ui::category_panel::CategoryPanel; use crate::ui::formula_panel::FormulaPanel; use crate::ui::grid::GridWidget; use crate::ui::help::HelpWidget; use crate::ui::import_wizard_ui::ImportWizardWidget; use crate::ui::tile_bar::TileBar; use crate::ui::view_panel::ViewPanel; 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(); } } pub fn run_tui( model: Model, file_path: Option, import_value: 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_value { 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 fill_line(left: String, right: &str, width: u16) -> String { let pad = " ".repeat((width as usize).saturating_sub(left.len() + right.len())); format!("{left}{pad}{right}") } fn centered_popup(area: Rect, width: u16, height: u16) -> Rect { let w = width.min(area.width); let h = height.min(area.height); let x = area.x + area.width.saturating_sub(w) / 2; let y = area.y + area.height.saturating_sub(h) / 2; Rect::new(x, y, w, h) } fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color) -> Rect { f.render_widget(Clear, popup); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .title(title); let inner = block.inner(popup); f.render_widget(block, popup); inner } 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), } } 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, size, 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 line = fill_line(title, right, area.width); 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; let grid_area; if side_open { let side_w = 32u16; let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(40), Constraint::Length(side_w)]) .split(area); grid_area = 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 { grid_area = area; } f.render_widget( GridWidget::new(&app.model, &app.mode, &app.search_query), grid_area, ); } fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { f.render_widget(TileBar::new(&app.model, &app.mode, app.tile_cat_idx), 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 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!(" {}{search_part} {msg}", mode_name(&app.mode)); let line = fill_line(left, &view_badge, area.width); f.render_widget(Paragraph::new(line).style(mode_style(&app.mode)), 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, area: Rect, app: &App) { let buf = if let AppMode::ExportPrompt { buffer } = &app.mode { buffer.as_str() } else { "" }; let popup = centered_popup(area, 64, 3); let inner = draw_popup_frame(f, popup, " Export CSV — path (Esc cancel) ", Color::Yellow); f.render_widget( Paragraph::new(format!("{buf}▌")).style(Style::default().fg(Color::Green)), inner, ); } fn draw_welcome(f: &mut Frame, area: Rect) { let popup = centered_popup(area, 58, 20); let inner = draw_popup_frame(f, popup, " Welcome to improvise ", Color::Blue); 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 JSON or CSV 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, ), ); } }