//! Top-level app state and the render/event dispatch. //! //! While no profile is open the picker takes the whole screen. Once one is //! open, a tab bar sits under the header and `Alt+1`/`Alt+2` (or `Ctrl+Tab`) //! switch between Templates and Log. Screen keys never receive Alt-modified //! digits, so text entry keeps working. use std::path::PathBuf; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Frame; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Tabs}; use ripgrow_core::{ Db, LoadUnit, create_profile_in, list_profiles, profiles_dir, seed_starter_content, }; use crate::screens::diagnostic::DiagnosticScreen; use crate::screens::generate::GenerateScreen; use crate::screens::history::{HistoryAction, HistoryScreen}; use crate::screens::log::LogScreen; use crate::screens::profile_picker::{PickerAction, ProfilePicker}; use crate::screens::templates::{TemplatesAction, TemplatesScreen}; pub struct App { picker: ProfilePicker, opened: Option, status: String, pub should_quit: bool, } struct Opened { path: PathBuf, db: Db, tab: Tab, templates: TemplatesScreen, log: LogScreen, generate: GenerateScreen, history: HistoryScreen, diagnostic: DiagnosticScreen, } #[derive(Clone, Copy, PartialEq, Eq)] enum Tab { Templates, Log, Generate, History, Diagnostic, } impl Tab { const ALL: [Tab; 5] = [ Tab::Templates, Tab::Log, Tab::Generate, Tab::History, Tab::Diagnostic, ]; fn title(self) -> &'static str { match self { Tab::Templates => "templates", Tab::Log => "log", Tab::Generate => "generate", Tab::History => "history", Tab::Diagnostic => "diagnostic", } } } impl App { pub fn new() -> Result { let profiles = list_profiles()?; Ok(Self { picker: ProfilePicker::new(profiles), opened: None, status: String::new(), should_quit: false, }) } pub fn render(&mut self, frame: &mut Frame) { use ratatui::layout::{Constraint, Direction, Layout}; let area = frame.area(); match &mut self.opened { Some(op) => { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ]) .split(area); let header = format!( "ripgrow | {} | {}", op.db.profile_name().ok().flatten().unwrap_or_default(), op.path.display() ); frame.render_widget(ratatui::widgets::Paragraph::new(header), chunks[0]); let titles: Vec = Tab::ALL .iter() .map(|t| Line::from(t.title())) .collect(); let idx = Tab::ALL.iter().position(|t| *t == op.tab).unwrap_or(0); let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::ALL)) .select(idx) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); frame.render_widget(tabs, chunks[1]); match op.tab { Tab::Templates => op.templates.render(frame, chunks[2]), Tab::Log => op.log.render(frame, chunks[2]), Tab::Generate => op.generate.render(frame, chunks[2], &op.db), Tab::History => op.history.render(frame, chunks[2], &op.db), Tab::Diagnostic => op.diagnostic.render(frame, chunks[2]), } } None => { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(1), Constraint::Min(1)]) .split(area); frame.render_widget( ratatui::widgets::Paragraph::new(Span::styled( format!("ripgrow {}", self.status), Style::default().add_modifier(Modifier::DIM), )), chunks[0], ); self.picker.render(frame, chunks[1]); } } } pub fn on_key(&mut self, key: KeyEvent) { if let Some(op) = self.opened.as_mut() { // Tab-switch keys handled at the app layer before dispatch. if key.modifiers.contains(KeyModifiers::ALT) { match key.code { KeyCode::Char('1') => { op.tab = Tab::Templates; return; } KeyCode::Char('2') => { op.tab = Tab::Log; return; } KeyCode::Char('3') => { op.tab = Tab::Generate; return; } KeyCode::Char('4') => { op.tab = Tab::History; return; } KeyCode::Char('5') => { op.tab = Tab::Diagnostic; return; } _ => {} } } if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { self.should_quit = true; return; } match op.tab { Tab::Templates => match op.templates.on_key(&op.db, key) { TemplatesAction::None => {} TemplatesAction::Quit => self.should_quit = true, TemplatesAction::Refresh => { // Reload downstream screens so newly-created // templates and tags appear without extra plumbing. if let Ok(next) = TemplatesScreen::load(&op.db) { op.templates = next; } if let Ok(next) = LogScreen::load(&op.db) { op.log = next; } if let Ok(next) = GenerateScreen::load(&op.db) { op.generate = next; } if let Ok(next) = HistoryScreen::load(&op.db) { op.history = next; } if let Ok(next) = DiagnosticScreen::load(&op.db) { op.diagnostic = next; } } }, Tab::Log => op.log.on_key(&op.db, key), Tab::Generate => op.generate.on_key(&op.db, key), Tab::History => match op.history.on_key(&op.db, key) { HistoryAction::None => {} HistoryAction::Quit => self.should_quit = true, }, Tab::Diagnostic => op.diagnostic.on_key(&op.db, key), } return; } match self.picker.on_key(key) { PickerAction::None => {} PickerAction::Quit => self.should_quit = true, PickerAction::Open(path) => self.open_profile(path), PickerAction::Create { display_name, unit } => { self.create_profile(&display_name, unit); } } } fn open_profile(&mut self, path: PathBuf) { let db = match Db::open(&path) { Ok(d) => d, Err(e) => { self.status = format!("open failed: {e}"); return; } }; let templates = match TemplatesScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load templates failed: {e}"); return; } }; let log = match LogScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load log failed: {e}"); return; } }; let generate = match GenerateScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load generate failed: {e}"); return; } }; let history = match HistoryScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load history failed: {e}"); return; } }; let diagnostic = match DiagnosticScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load diagnostic failed: {e}"); return; } }; self.status = format!("opened {}", path.display()); self.opened = Some(Opened { path, db, tab: Tab::Templates, templates, log, generate, history, diagnostic, }); } fn create_profile(&mut self, display_name: &str, unit: LoadUnit) { let dir = match profiles_dir() { Ok(d) => d, Err(e) => { self.status = format!("profiles dir: {e}"); return; } }; match create_profile_in(&dir, display_name, unit) { Ok((profile, db)) => { if let Err(e) = seed_starter_content(&db, unit) { self.status = format!("seed failed: {e}"); return; } let templates = match TemplatesScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load templates failed: {e}"); return; } }; let log = match LogScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load log failed: {e}"); return; } }; let generate = match GenerateScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load generate failed: {e}"); return; } }; let history = match HistoryScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load history failed: {e}"); return; } }; let diagnostic = match DiagnosticScreen::load(&db) { Ok(s) => s, Err(e) => { self.status = format!("load diagnostic failed: {e}"); return; } }; self.status = format!("created {}", profile.slug); self.opened = Some(Opened { path: profile.path, db, tab: Tab::Templates, templates, log, generate, history, diagnostic, }); } Err(e) => self.status = format!("create failed: {e}"), } if let Ok(list) = list_profiles() { self.picker.profiles = list; } } }