use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState}, Frame, }; use crate::app::{ App, Focus, ModalKind, GLOBAL_CHECKSUM, GLOBAL_COUNT, GLOBAL_HUT_ID, GLOBAL_HUT_TYPE, GLOBAL_LOT_STATUS, GLOBAL_PATH, GLOBAL_TARE, ROW_COUNT, ROW_MAT_ID, ROW_QUANTITY, ROW_UOM, }; const GLOBAL_LABELS: &[&str] = &[ "Hut ID", "Checksum", "Tare", "Lot Status", "Path", "Hut Type", ]; const ROW_LABELS: &[&str] = &["Material ID", "Quantity", "UOM"]; // ── colour palette ──────────────────────────────────────────────────────────── const C_FOCUSED: Color = Color::Cyan; const C_UNFOCUSED: Color = Color::DarkGray; const C_ACCENT: Color = Color::Yellow; const C_ROW_SEL: Color = Color::Blue; const C_BORDER: Color = Color::Gray; const C_HEADER: Color = Color::White; fn focused_style(is_focused: bool) -> Style { if is_focused { Style::default().fg(C_FOCUSED).add_modifier(Modifier::BOLD) } else { Style::default().fg(C_UNFOCUSED) } } fn focused_block<'a>(title: &'a str, is_focused: bool) -> Block<'a> { Block::default() .title(title) .borders(Borders::ALL) .border_style(if is_focused { Style::default().fg(C_FOCUSED) } else { Style::default().fg(C_BORDER) }) } // ── main draw ──────────────────────────────────────────────────────────────── pub fn draw(f: &mut Frame, app: &App) { let area = f.area(); // Overall vertical split: globals | lots | help bar let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(10), // global fields Constraint::Min(6), // lot rows Constraint::Length(3), // help bar ]) .split(area); draw_globals(f, app, chunks[0]); draw_lots(f, app, chunks[1]); draw_help(f, chunks[2]); if let Some(modal) = &app.modal { draw_modal(f, modal, area); } } // ── globals panel ───────────────────────────────────────────────────────────── fn draw_globals(f: &mut Frame, app: &App, area: Rect) { let outer = Block::default() .title(" Global Settings ") .borders(Borders::ALL) .border_style(Style::default().fg(C_ACCENT)); let inner = outer.inner(area); f.render_widget(outer, area); // Two rows of three fields each let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), ]) .split(inner); // Row 0: Hut ID | Checksum | Tare let row0 = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Ratio(2, 5), Constraint::Ratio(1, 5), Constraint::Ratio(2, 5), ]) .split(rows[0]); // Row 1: Lot Status | Path | Hut Type let row1 = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), ]) .split(rows[1]); let field_areas = [ row0[0], // GLOBAL_HUT_ID row0[1], // GLOBAL_CHECKSUM row0[2], // GLOBAL_TARE row1[0], // GLOBAL_LOT_STATUS row1[1], // GLOBAL_PATH row1[2], // GLOBAL_HUT_TYPE ]; for i in 0..GLOBAL_COUNT { let is_focused = app.focus == Focus::GlobalField(i); let label = GLOBAL_LABELS[i]; let value: String = if i == GLOBAL_CHECKSUM { let checked = app.global[i] == "true"; format!("[{}] (Space)", if checked { "x" } else { " " }) } else if i == GLOBAL_LOT_STATUS { format!("{} (←/→)", app.global[i]) } else { app.global[i].clone() }; let display = if is_focused { format!("{} █", value) } else { value }; let p = Paragraph::new(display) .block(focused_block(label, is_focused)) .style(focused_style(is_focused)); f.render_widget(p, field_areas[i]); } } // ── lots table ──────────────────────────────────────────────────────────────── fn draw_lots(f: &mut Frame, app: &App, area: Rect) { let outer = Block::default() .title(" Lot Rows (+) Add (-) Remove ") .borders(Borders::ALL) .border_style(Style::default().fg(C_ACCENT)); let inner = outer.inner(area); f.render_widget(outer, area); if app.lots.is_empty() { let hint = Paragraph::new("No lots yet — press + to add one") .style(Style::default().fg(C_UNFOCUSED)); f.render_widget(hint, inner); return; } // Column widths let col_constraints = [ Constraint::Length(4), // # Constraint::Min(18), // Material ID Constraint::Length(12), // Quantity Constraint::Length(8), // UOM ]; let header_cells = ["#", "Material ID", "Quantity", "UOM"] .iter() .map(|h| Cell::from(*h).style(Style::default().fg(C_HEADER).add_modifier(Modifier::BOLD))); let header = Row::new(header_cells).height(1).bottom_margin(0); let rows: Vec = app .lots .iter() .enumerate() .map(|(row_idx, entry)| { let cells: Vec = (0..ROW_COUNT + 1) .map(|col| { if col == 0 { // row number Cell::from(format!("{:>2}", row_idx + 1)) .style(Style::default().fg(C_UNFOCUSED)) } else { let field_idx = col - 1; let is_focused = app.focus == Focus::LotField(row_idx, field_idx); let text = if is_focused { format!("{} █", entry.fields[field_idx]) } else { entry.fields[field_idx].clone() }; Cell::from(text).style(focused_style(is_focused)) } }) .collect(); let row_selected = matches!(&app.focus, Focus::LotRow(r) | Focus::LotField(r, _) if *r == row_idx); let row_style = if row_selected { Style::default().bg(Color::Rgb(20, 30, 50)) } else { Style::default() }; Row::new(cells).style(row_style).height(1) }) .collect(); let table = Table::new(rows, col_constraints) .header(header) .block(Block::default()) .row_highlight_style(Style::default().bg(C_ROW_SEL)); let mut state = TableState::default(); f.render_stateful_widget(table, inner, &mut state); } // ── help bar ────────────────────────────────────────────────────────────────── fn draw_help(f: &mut Frame, area: Rect) { let spans = vec![ Span::styled(" Tab ", Style::default().fg(Color::Black).bg(C_ACCENT)), Span::raw(" Next field "), Span::styled(" + ", Style::default().fg(Color::Black).bg(Color::Green)), Span::raw(" Add lot "), Span::styled(" - ", Style::default().fg(Color::Black).bg(Color::Red)), Span::raw(" Remove lot "), Span::styled(" c ", Style::default().fg(Color::Black).bg(Color::Cyan)), Span::raw(" Output CSV "), Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::Magenta)), Span::raw(" Quit "), ]; let help = Paragraph::new(Line::from(spans)).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(C_BORDER)), ); f.render_widget(help, area); } // ── modal overlay ───────────────────────────────────────────────────────────── fn draw_modal(f: &mut Frame, kind: &ModalKind, area: Rect) { let (title, body) = match kind { ModalKind::ConfirmClose => ( " Output CSV and Close ", "Output generated CSV to stdout and exit?\n\n [y] Yes [n] Cancel", ), ModalKind::ConfirmQuit => ( " Quit Without Output ", "Quit without outputting anything?\n\n [y] Yes [n] Cancel", ), }; let popup_width = 50u16; let popup_height = 7u16; let x = area.x + area.width.saturating_sub(popup_width) / 2; let y = area.y + area.height.saturating_sub(popup_height) / 2; let popup_area = Rect::new( x, y, popup_width.min(area.width), popup_height.min(area.height), ); f.render_widget(Clear, popup_area); let color = match kind { ModalKind::ConfirmClose => Color::Cyan, ModalKind::ConfirmQuit => Color::Magenta, }; let p = Paragraph::new(body) .block( Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(color).add_modifier(Modifier::BOLD)), ) .style(Style::default().fg(C_HEADER)); f.render_widget(p, popup_area); }