//! TUI event loop. `launch` sets up the terminal, spawns the render/input //! task, and drives the per-screen handlers. Split out of tui/mod.rs. use super::{ App, AppEvent, AppHandle, CrosstermBackend, DataPayload, KeyCode, KeyModifiers, MnwApiClient, PathBuf, Screen, Terminal, TerminalHandle, UserInfo, analytics, blog, collections, handle_analytics_input, handle_blog_input, handle_collections_input, handle_home_input, handle_item_input, handle_keys_input, handle_project_input, handle_promo_input, handle_settings_input, handle_tiers_input, handle_upload_input, home, item, keys, load_blog_posts, load_home_data, load_project_items, load_staged_files, mpsc, parse_key, project, promo, settings, tiers, upload, }; /// Launch the TUI event loop in a background task. #[allow(clippy::too_many_arguments)] pub(crate) fn launch( writer: TerminalHandle, user: UserInfo, cols: u16, rows: u16, session_handle: russh::server::Handle, channel_id: russh::ChannelId, api: MnwApiClient, staging_dir: PathBuf, ) -> anyhow::Result { let mut writer = writer; // Enter alternate screen, hide cursor, enable raw-like mode use std::io::Write; let _ = writer.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H"); let _ = writer.flush(); let backend = CrosstermBackend::new(writer); let options = ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, cols, rows)), }; let mut terminal = Terminal::with_options(backend, options)?; let (tx, mut rx) = mpsc::channel::(64); let handle = AppHandle { tx: tx.clone() }; // Kick off initial data load let user_id = user.user_id.clone(); let api_clone = api.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { load_home_data(&api_clone, &user_id, &tx_clone).await; }); tokio::spawn(async move { let mut app = App::new(user); let mut screen = Screen::Home; let staging_dir = staging_dir; /// Write escape codes to leave alternate screen and restore cursor. fn cleanup(terminal: &mut Terminal>) { use std::io::Write; let be = terminal.backend_mut(); let _ = be.write_all(b"\x1b[?25h\x1b[?1049l"); let _ = be.flush(); } // Initial render (loading state) if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) { tracing::error!(error = ?e, "TUI: initial render failed"); cleanup(&mut terminal); return; } while let Some(event) = rx.recv().await { match event { AppEvent::Input(data) => { if let Some(key) = parse_key(&data) { // Global quit if (key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')) || matches!( (&screen, key.code), (Screen::Home, KeyCode::Char('q' | 'Q')) ) { tracing::info!(user = %app.user.username, "user quit"); cleanup(&mut terminal); let _ = session_handle.close(channel_id).await; return; } match screen { Screen::Home => { handle_home_input( key, &mut app, &mut screen, &api, &tx, &staging_dir, ) .await; } Screen::Project(_) => { handle_project_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Upload => { handle_upload_input( key, &mut app, &mut screen, &api, &tx, &staging_dir, ) .await; } Screen::Item(..) => { handle_item_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Blog(..) => { handle_blog_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Promo => { handle_promo_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Keys(..) => { handle_keys_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Analytics => { handle_analytics_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Settings => { handle_settings_input(key, &mut app, &mut screen, &api, &tx).await; } Screen::Collections => { handle_collections_input(key, &mut app, &mut screen, &api, &tx) .await; } Screen::Tiers(..) => { handle_tiers_input(key, &mut app, &mut screen).await; } } } } AppEvent::Resize(cols, rows) => { let rect = ratatui::layout::Rect::new(0, 0, cols, rows); let _ = terminal.resize(rect); } AppEvent::DataLoaded(payload) => match payload { DataPayload::Home { projects, stats } => { app.projects = projects; app.stats = Some(stats); app.loading = false; app.selected_index = 0; } DataPayload::ProjectItems { items } => { app.items = items; app.loading = false; app.selected_index = 0; app.selected_items.clear(); } DataPayload::StagedFiles { files, storage } => { app.staged_files = files; if let Some(s) = storage { app.storage_info = Some(s); } app.sync_metadata(); app.loading = false; if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() { app.selected_index = app.staged_files.len() - 1; } } DataPayload::ItemDetail { detail, versions } => { app.item_detail = Some(detail); app.item_versions = versions; app.loading = false; app.selected_index = 0; } DataPayload::ItemUpdated { detail } => { app.item_detail = Some(detail); app.item_status = Some("Updated".to_string()); app.item_editing = None; app.edit_buffer.clear(); } DataPayload::ItemDeleted => { app.item_status = Some("Deleted".to_string()); // Navigate back to project view if let Screen::Item(project_idx, _) = &screen { let pidx = *project_idx; screen = Screen::Project(pidx); app.item_detail = None; app.item_versions.clear(); app.item_status = None; app.selected_index = 0; app.loading = true; if let Some(p) = app.projects.get(pidx) { let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } } DataPayload::ItemActionError { error } => { app.item_status = Some(format!("Error: {error}")); } DataPayload::BlogPosts { posts } => { app.blog_posts = posts; app.loading = false; app.selected_index = 0; } DataPayload::BlogCreated => { app.blog_creating = false; app.blog_create_step = None; app.blog_create_title.clear(); app.blog_create_body.clear(); app.edit_buffer.clear(); app.blog_status = Some("Post created".to_string()); // Reload blog posts if let Screen::Blog(_, ref project_id) = screen { let api = api.clone(); let user_id = app.user.user_id.clone(); let project_id = project_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_blog_posts(&api, &user_id, &project_id, &tx).await; }); } } DataPayload::PromoCodes { codes } => { app.promo_codes = codes; app.loading = false; app.selected_index = 0; } DataPayload::LicenseKeys { keys: k } => { app.license_keys = k; app.loading = false; app.selected_index = 0; } DataPayload::GenericSuccess { message } => { // Use as status on whatever screen is active match screen { Screen::Blog(..) => app.blog_status = Some(message), Screen::Promo => app.promo_status = Some(message), Screen::Keys(..) => app.keys_status = Some(message), _ => {} } } DataPayload::GenericError { error } => { let msg = format!("Error: {error}"); match screen { Screen::Blog(..) => app.blog_status = Some(msg), Screen::Promo => { app.promo_status = Some(msg); app.promo_editing_step = None; } Screen::Keys(..) => app.keys_status = Some(msg), Screen::Analytics => app.analytics_status = Some(msg), Screen::Settings => app.settings_status = Some(msg), _ => app.item_status = Some(msg), } } DataPayload::Analytics { data } => { app.analytics_data = Some(data); app.loading = false; } DataPayload::Transactions { txs } => { app.transactions = txs; app.loading = false; app.selected_index = 0; } DataPayload::ExportCsv { csv, row_count } => { app.analytics_status = Some(format!("Exported {} rows ({} bytes)", row_count, csv.len())); } DataPayload::Settings { keys, storage } => { app.ssh_keys = keys; if let Some(s) = storage { app.storage_info = Some(s); } app.loading = false; app.selected_index = 0; } DataPayload::ItemTags { tags } => { app.item_tags = tags; } DataPayload::TagSearchResults { results } => { app.tag_search_results = results; app.tag_searching = false; } DataPayload::CollectionsList { collections: c } => { app.collections = c; app.loading = false; app.selected_index = 0; } DataPayload::TiersList { tiers: t } => { app.tiers = t; app.loading = false; app.selected_index = 0; } DataPayload::BulkActionComplete { message } => { app.item_status = Some(message); app.selected_items.clear(); // Reload project items if let Screen::Project(pidx) = &screen && let Some(p) = app.projects.get(*pidx) { app.loading = true; let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } DataPayload::ProjectReload { project_idx } => { if let Some(p) = app.projects.get(project_idx) { app.loading = true; let api = api.clone(); let project_id = p.id.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_project_items(&api, &project_id, &user_id, &tx).await; }); } } DataPayload::PublishResult { filename, success, error, } => { app.publishing = false; if success { app.upload_status = Some(format!("Published {filename}")); // Reload staged files let staging_dir = staging_dir.clone(); let api = api.clone(); let user_id = app.user.user_id.clone(); let tx = tx.clone(); tokio::spawn(async move { load_staged_files(&staging_dir, &api, &user_id, &tx).await; }); } else { app.upload_status = Some(format!( "Error: {}", error.unwrap_or_else(|| "unknown error".to_string()) )); } } }, } // Re-render after every event if let Err(e) = terminal.draw(|frame| match &screen { Screen::Home => home::render(frame, &app), Screen::Project(idx) => { if let Some(p) = app.projects.get(*idx) { project::render(frame, &app, p); } else { home::render(frame, &app); } } Screen::Upload => upload::render(frame, &app), Screen::Item(..) => item::render(frame, &app), Screen::Blog(..) => blog::render(frame, &app), Screen::Promo => promo::render(frame, &app), Screen::Keys(..) => keys::render(frame, &app), Screen::Analytics => analytics::render(frame, &app), Screen::Settings => settings::render(frame, &app), Screen::Collections => collections::render(frame, &app), Screen::Tiers(..) => tiers::render(frame, &app), }) { tracing::error!(error = ?e, "render failed"); cleanup(&mut terminal); return; } } // Event loop ended (channel dropped) cleanup(&mut terminal); }); Ok(handle) }