//! TUI application state and event loop. pub(crate) mod analytics; pub(crate) mod blog; pub(crate) mod collections; pub(crate) mod home; mod input; pub(crate) mod item; pub(crate) mod keys; mod loading; pub(crate) mod project; pub(crate) mod promo; pub(crate) mod settings; pub(crate) mod theme; pub(crate) mod tiers; pub(crate) mod upload; use std::collections::HashSet; use std::path::PathBuf; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use tokio::sync::mpsc; use crate::api::{ AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction, UserInfo, Version, }; use crate::currency::Currency; use crate::ssh::terminal::TerminalHandle; use crate::staging::{self, StagedFile}; use input::{ 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, }; use loading::{load_blog_posts, load_home_data, load_project_items, load_staged_files}; mod run; pub(crate) use run::launch; /// A screen's body area, indented by `cells`. /// /// Every screen draws its tables two cells in (four, under a settings /// sub-heading). The indent belongs to the area, never to a heading string or /// to the first cell of a row: a table describes its columns, and putting /// presentation in the name a cell is addressed by is a bug waiting to happen. pub(crate) fn indent(area: ratatui::layout::Rect, cells: u16) -> ratatui::layout::Rect { ratatui::layout::Rect { x: area.x + cells, width: area.width.saturating_sub(cells), ..area } } /// Events sent to the TUI event loop. pub(crate) enum AppEvent { /// Raw input bytes from the SSH channel. Input(Vec), /// Terminal resize. Resize(u16, u16), /// Data loaded from the API. DataLoaded(DataPayload), } /// Payload variants for async data loading. pub(crate) enum DataPayload { Home { projects: Vec, stats: CreatorStats, }, ProjectItems { items: Vec, }, StagedFiles { files: Vec, storage: Option, }, PublishResult { filename: String, success: bool, error: Option, }, ItemDetail { detail: ItemDetail, versions: Vec, }, ItemUpdated { detail: ItemDetail, }, ItemDeleted, ItemActionError { error: String, }, /// Signal to reload the project items list after a mutation. #[allow(dead_code)] ProjectReload { project_idx: usize, }, BlogPosts { posts: Vec, }, BlogCreated, PromoCodes { codes: Vec, }, LicenseKeys { keys: Vec, }, GenericSuccess { message: String, }, GenericError { error: String, }, Analytics { data: AnalyticsData, }, Transactions { txs: Vec, }, ExportCsv { csv: String, row_count: usize, }, Settings { keys: Vec, storage: Option, }, ItemTags { tags: Vec, }, TagSearchResults { results: Vec, }, CollectionsList { collections: Vec, }, TiersList { tiers: Vec, }, BulkActionComplete { message: String, }, } /// Handle for sending events to a running TUI session. #[derive(Clone)] pub(crate) struct AppHandle { tx: mpsc::Sender, } impl AppHandle { pub(crate) async fn send_input(&self, data: &[u8]) { let _ = self.tx.send(AppEvent::Input(data.to_vec())).await; } pub(crate) async fn send_resize(&self, cols: u16, rows: u16) { let _ = self.tx.send(AppEvent::Resize(cols, rows)).await; } } /// Active screen in the TUI. enum Screen { Home, /// Project detail view. Index is into `app.projects`. Project(usize), /// Upload management screen. Upload, /// Item detail view. Stores (project_index, item_id). Item(usize, String), /// Blog post list for a project. Stores (project_index, project_id). Blog(usize, String), /// Promo code management. Promo, /// License key management for an item. Stores (project_index, item_id). Keys(usize, String), /// Analytics dashboard. Analytics, /// Settings screen (profile, storage, SSH keys). Settings, /// Collections management. Collections, /// Subscription tiers for a project. Stores (project_index, project_id). /// The id is carried for the tier-mutation calls the screen will need; the /// render path only uses the index today. Tiers(usize, #[allow(dead_code)] String), } /// User-editable metadata for a staged file. #[derive(Debug, Clone, Default)] pub(crate) struct FileMetadata { pub title: Option, pub project_idx: Option, pub project_name: Option, pub price_cents: i32, } /// Which field is being edited on the upload screen. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum EditField { Title, Project, Price, } /// Steps for creating a blog post. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum BlogCreateStep { Title, Body, /// Optional scheduling step — enter datetime or leave empty to publish as draft. Schedule, } /// Steps for creating a promo code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PromoCreateStep { Code, Discount, } /// Pending destructive action awaiting confirmation. #[derive(Debug, Clone)] pub(crate) enum ConfirmAction { DeleteItem, DeleteBlogPost { post_idx: usize }, DeletePromoCode { code_idx: usize }, RevokeLicenseKey { key_idx: usize }, BulkPublish { count: usize }, BulkUnpublish { count: usize }, BulkDelete { count: usize }, } /// Application state shared across screens. pub(crate) struct App { pub user: UserInfo, /// The colours every screen draws in, resolved once per session from the /// creator's selection and their terminal's capabilities. /// /// Held for the life of the session rather than re-resolved per frame, and /// `Copy`, so a screen reads it without a clone. Re-theming mid-session /// would mean rebuilding this and is not something any surface offers yet. pub theme: makeover_tui::Theme, pub projects: Vec, pub stats: Option, pub items: Vec, pub selected_index: usize, pub selected_items: HashSet, pub loading: bool, pub staged_files: Vec, pub storage_info: Option, pub file_metadata: Vec, pub upload_status: Option, pub editing_field: Option, pub edit_buffer: String, pub publishing: bool, pub item_detail: Option, pub item_versions: Vec, pub item_status: Option, pub item_editing: Option, // Blog pub blog_posts: Vec, pub blog_project_title: Option, pub blog_status: Option, pub blog_creating: bool, pub blog_create_step: Option, pub blog_create_title: String, pub blog_create_body: String, // Promo codes pub promo_codes: Vec, pub promo_status: Option, pub promo_editing_step: Option, pub promo_create_code: String, pub promo_create_discount: String, // License keys pub license_keys: Vec, pub keys_item_title: Option, pub keys_status: Option, // Analytics pub analytics_data: Option, pub analytics_range: String, pub analytics_status: Option, pub analytics_show_transactions: bool, pub transactions: Vec, // Settings pub ssh_keys: Vec, pub settings_status: Option, // Tags (on item detail) pub item_tags: Vec, pub tag_search_results: Vec, pub tag_searching: bool, // Collections pub collections: Vec, pub collections_status: Option, // Tiers pub tiers: Vec, pub tiers_project_title: Option, pub tiers_status: Option, // Confirmation dialog pub confirm_action: Option, } impl App { /// The viewer's own settlement currency. /// /// The right currency for every amount the server sends without one: this /// creator's prices, their period totals, their transactions. It is *not* /// right for per-project revenue, which carries its own currency because a /// revenue split is paid in the currency of the project that earned it. pub(crate) fn currency(&self) -> Currency { self.user.settlement_currency } fn new(user: UserInfo, theme: makeover_tui::Theme) -> Self { Self { user, theme, projects: Vec::new(), stats: None, items: Vec::new(), selected_index: 0, selected_items: HashSet::new(), loading: true, staged_files: Vec::new(), storage_info: None, file_metadata: Vec::new(), upload_status: None, editing_field: None, edit_buffer: String::new(), publishing: false, item_detail: None, item_versions: Vec::new(), item_status: None, item_editing: None, blog_posts: Vec::new(), blog_project_title: None, blog_status: None, blog_creating: false, blog_create_step: None, blog_create_title: String::new(), blog_create_body: String::new(), promo_codes: Vec::new(), promo_status: None, promo_editing_step: None, promo_create_code: String::new(), promo_create_discount: String::new(), license_keys: Vec::new(), keys_item_title: None, keys_status: None, analytics_data: None, analytics_range: "30d".to_string(), analytics_status: None, analytics_show_transactions: false, transactions: Vec::new(), ssh_keys: Vec::new(), settings_status: None, item_tags: Vec::new(), tag_search_results: Vec::new(), tag_searching: false, collections: Vec::new(), collections_status: None, tiers: Vec::new(), tiers_project_title: None, tiers_status: None, confirm_action: None, } } fn list_len(&self, screen: &Screen) -> usize { match screen { Screen::Home => self.projects.len(), Screen::Project(_) => self.items.len(), Screen::Upload => self.staged_files.len(), Screen::Item(..) => self.item_versions.len(), Screen::Blog(..) => self.blog_posts.len(), Screen::Promo => self.promo_codes.len(), Screen::Keys(..) => self.license_keys.len(), Screen::Analytics => self.transactions.len(), Screen::Settings => self.ssh_keys.len(), Screen::Collections => self.collections.len(), Screen::Tiers(..) => self.tiers.len(), } } fn move_up(&mut self, screen: &Screen) { if self.selected_index > 0 { self.selected_index -= 1; } else { // Wrap to bottom let len = self.list_len(screen); if len > 0 { self.selected_index = len - 1; } } } fn move_down(&mut self, screen: &Screen) { let len = self.list_len(screen); if len > 0 { if self.selected_index < len - 1 { self.selected_index += 1; } else { // Wrap to top self.selected_index = 0; } } } /// Ensure file_metadata vec matches staged_files length. fn sync_metadata(&mut self) { while self.file_metadata.len() < self.staged_files.len() { let idx = self.file_metadata.len(); let title = staging::derive_title(&self.staged_files[idx].filename); self.file_metadata.push(FileMetadata { title: Some(title), ..Default::default() }); } self.file_metadata.truncate(self.staged_files.len()); } } fn format_edit_prompt(field: EditField, buffer: &str) -> String { let field_name = match field { EditField::Title => "Title", EditField::Project => "Project #", EditField::Price => "Price ($)", }; format!("{field_name}: {buffer}_") } // NOTE: parse_price, parse_key, and tests are below. // All handle_*_input functions are in input.rs. // All load_* functions and publish_file are in loading.rs. fn parse_price(input: &str) -> i32 { // Accept "5", "5.00", "5.99", "0" etc. if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") { return 0; } if let Some((dollars, cents)) = input.split_once('.') { let d: i32 = dollars.parse().unwrap_or(0); let cents_str = cents.get(..2).unwrap_or(cents); let c: i32 = if cents_str.len() == 1 { cents_str.parse::().unwrap_or(0) * 10 } else { cents_str.parse().unwrap_or(0) }; d * 100 + c } else { input.parse::().unwrap_or(0) * 100 } } /// Parse raw SSH input bytes into a crossterm KeyEvent. fn parse_key(data: &[u8]) -> Option { match data { // Ctrl+C [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), // Escape [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), // Enter [13 | 10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), // Tab [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), // Backspace [127 | 8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), // Arrow keys [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)), [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), // Single printable ASCII byte [b] if b.is_ascii_graphic() || *b == b' ' => { Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE)) } // Ctrl+letter (1-26 maps to a-z) [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new( KeyCode::Char((b + b'a' - 1) as char), KeyModifiers::CONTROL, )), _ => None, } } #[cfg(test)] mod tests { use super::*; /// An app with no data loaded, on a known theme. fn app_on_a_fixed_theme() -> App { App::new( UserInfo { user_id: "u1".to_string(), username: "creator".to_string(), display_name: None, creator_tier: Some("small_files".to_string()), can_create_projects: true, suspended: false, actor_token: String::new(), settlement_currency: Currency::default(), theme_id: None, }, theme::tests::fixed(), ) } /// Every colour a rendered frame can put on screen, from one theme. /// /// `Color::Reset` is in the set because that is what an unstyled cell holds, /// and `paint_page` has already given the whole viewport the theme's own /// page colours underneath it. fn colors_of(theme: &makeover_tui::Theme) -> Vec { let mut colors = vec![ ratatui::style::Color::Reset, theme.surface_page, theme.surface_raised, theme.surface_sunken, theme.surface_overlay, theme.content_primary, theme.content_secondary, theme.content_muted, theme.action_primary, theme.status_danger, theme.status_success, theme.status_warning, theme.status_info, theme.line_border, theme.border_strong, theme.bevel_light, theme.bevel_dark, ]; colors.extend(theme.surface_well); colors.extend(theme.category); colors } /// An app with one project in it, so a screen renders a table rather than /// its empty state. fn app_with_a_project() -> App { let mut app = app_on_a_fixed_theme(); app.loading = false; app.projects = vec![Project { id: "p1".to_string(), slug: "field-recordings".to_string(), title: "Field Recordings".to_string(), project_type: "sample_pack".to_string(), is_public: true, item_count: 12, revenue_cents: 4200, currency: Currency::default(), revenue_cents_by_currency: std::collections::BTreeMap::new(), }]; app } /// What the home screen draws at a given width. fn home_at(width: u16) -> String { let app = app_with_a_project(); let backend = ratatui::backend::TestBackend::new(width, 12); let mut terminal = Terminal::new(backend).expect("test backend"); terminal .draw(|frame| { run::paint_page(frame, &app); home::render(frame, &app); }) .expect("home renders"); terminal .backend() .buffer() .content() .iter() .map(ratatui::buffer::Cell::symbol) .collect() } /// A narrow window drops the columns that can be spared and keeps the one /// that says which row this is. /// /// The screens narrowed at no width before `makeover-tui`'s table: every /// one of them handed ratatui a hand-written `Constraint` list and /// overflowed. What drops is a property of the column now, so this asserts /// against the headings rather than against positions -- inserting a column /// left of the cut is exactly the bug `Priority` exists to prevent. #[test] fn the_project_table_sheds_columns_before_it_sheds_the_title() { let wide = home_at(120); assert!(wide.contains("Title"), "the title heading is always drawn"); assert!(wide.contains("Items"), "a wide window keeps every column"); let narrow = home_at(34); assert!( narrow.contains("Title"), "the essential column survives every width", ); assert!( !narrow.contains("Items"), "an optional column drops before an essential one", ); } /// No screen may paint a colour that is not in the theme. /// /// This is the guard the port exists to make possible, and the terminal's /// equivalent of the frontend lint the web surfaces carry: a `Color::Red` /// added back to a screen in six months fails here rather than being noticed /// by a creator whose theme has no red in it. Asserted over the rendered /// buffer rather than by grepping the source, so it also catches a literal /// arriving through a widget's own default. #[test] fn every_rendered_colour_comes_from_the_theme() { // With a project in it, so the table's own tones are on the buffer this // reads. An empty screen renders its empty state and asserts nothing // about the thing most likely to name a colour. let app = app_with_a_project(); let permitted = colors_of(&app.theme); let backend = ratatui::backend::TestBackend::new(120, 40); let mut terminal = Terminal::new(backend).expect("test backend"); terminal .draw(|frame| { run::paint_page(frame, &app); home::render(frame, &app); }) .expect("home renders"); for cell in terminal.backend().buffer().content() { assert!( permitted.contains(&cell.fg), "foreground {:?} is in no theme intent", cell.fg, ); assert!( permitted.contains(&cell.bg), "background {:?} is in no theme intent", cell.bg, ); } } // The page is painted, not left to the terminal's default. A transparent // background is what the port set out to remove, and it looks identical to // a correct one on a terminal whose own colours happen to match. #[test] fn the_page_surface_is_painted_under_every_screen() { let app = app_on_a_fixed_theme(); let backend = ratatui::backend::TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).expect("test backend"); terminal .draw(|frame| run::paint_page(frame, &app)) .expect("the page paints"); for cell in terminal.backend().buffer().content() { assert_eq!(cell.bg, app.theme.surface_page); } } #[test] fn parse_price_whole_dollars() { assert_eq!(parse_price("5"), 500); assert_eq!(parse_price("10"), 1000); } #[test] fn parse_price_with_cents() { assert_eq!(parse_price("5.99"), 599); assert_eq!(parse_price("0.50"), 50); } #[test] fn parse_price_single_digit_cents() { assert_eq!(parse_price("5.5"), 550); assert_eq!(parse_price("1.1"), 110); } #[test] fn parse_price_free() { assert_eq!(parse_price("0"), 0); assert_eq!(parse_price("free"), 0); assert_eq!(parse_price("FREE"), 0); assert_eq!(parse_price(""), 0); } #[test] fn parse_price_truncates_extra_decimals() { assert_eq!(parse_price("5.999"), 599); } #[test] fn parse_key_ctrl_c() { let key = parse_key(&[3]).unwrap(); assert_eq!(key.code, KeyCode::Char('c')); assert!(key.modifiers.contains(KeyModifiers::CONTROL)); } #[test] fn parse_key_enter() { let key = parse_key(&[13]).unwrap(); assert_eq!(key.code, KeyCode::Enter); } #[test] fn parse_key_arrow_up() { let key = parse_key(&[27, 91, 65]).unwrap(); assert_eq!(key.code, KeyCode::Up); } #[test] fn parse_key_printable_char() { let key = parse_key(b"a").unwrap(); assert_eq!(key.code, KeyCode::Char('a')); } #[test] fn parse_key_unknown() { assert!(parse_key(&[27, 91, 100, 100]).is_none()); } }