From da93145de5ce137005d3ad40945a60be63ed8bc0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 1 Apr 2026 08:54:22 -0700 Subject: [PATCH] refactor: introduce draw module --- src/draw.rs | 392 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 388 +-------------------------------------------------- 2 files changed, 394 insertions(+), 386 deletions(-) create mode 100644 src/draw.rs diff --git a/src/draw.rs b/src/draw.rs new file mode 100644 index 0000000..2dceeb9 --- /dev/null +++ b/src/draw.rs @@ -0,0 +1,392 @@ +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), 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, + ), + ); + } +} diff --git a/src/main.rs b/src/main.rs index c34a421..26f1270 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,34 +5,14 @@ mod model; mod persistence; mod ui; mod view; +mod draw; -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; +use draw::run_tui; fn main() -> Result<()> { let args: Vec = std::env::args().collect(); @@ -234,367 +214,3 @@ fn get_initial_model(file_path: &Option) -> Result { 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_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), 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, - ), - ); - } -}