Skip to main content

max / makenotwork

22.7 KB · 725 lines History Blame Raw
1 //! TUI application state and event loop.
2
3 pub(crate) mod analytics;
4 pub(crate) mod blog;
5 pub(crate) mod collections;
6 pub(crate) mod home;
7 mod input;
8 pub(crate) mod item;
9 pub(crate) mod keys;
10 mod loading;
11 pub(crate) mod project;
12 pub(crate) mod promo;
13 pub(crate) mod settings;
14 pub(crate) mod theme;
15 pub(crate) mod tiers;
16 pub(crate) mod upload;
17
18 use std::collections::HashSet;
19 use std::path::PathBuf;
20
21 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
22 use ratatui::Terminal;
23 use ratatui::backend::CrosstermBackend;
24 use tokio::sync::mpsc;
25
26 use crate::api::{
27 AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey,
28 MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction,
29 UserInfo, Version,
30 };
31 use crate::currency::Currency;
32 use crate::ssh::terminal::TerminalHandle;
33 use crate::staging::{self, StagedFile};
34
35 use input::{
36 handle_analytics_input, handle_blog_input, handle_collections_input, handle_home_input,
37 handle_item_input, handle_keys_input, handle_project_input, handle_promo_input,
38 handle_settings_input, handle_tiers_input, handle_upload_input,
39 };
40 use loading::{load_blog_posts, load_home_data, load_project_items, load_staged_files};
41
42 mod run;
43 pub(crate) use run::launch;
44
45 /// A screen's body area, indented by `cells`.
46 ///
47 /// Every screen draws its tables two cells in (four, under a settings
48 /// sub-heading). The indent belongs to the area, never to a heading string or
49 /// to the first cell of a row: a table describes its columns, and putting
50 /// presentation in the name a cell is addressed by is a bug waiting to happen.
51 pub(crate) fn indent(area: ratatui::layout::Rect, cells: u16) -> ratatui::layout::Rect {
52 ratatui::layout::Rect {
53 x: area.x + cells,
54 width: area.width.saturating_sub(cells),
55 ..area
56 }
57 }
58
59 /// Events sent to the TUI event loop.
60 pub(crate) enum AppEvent {
61 /// Raw input bytes from the SSH channel.
62 Input(Vec<u8>),
63 /// Terminal resize.
64 Resize(u16, u16),
65 /// Data loaded from the API.
66 DataLoaded(DataPayload),
67 }
68
69 /// Payload variants for async data loading.
70 pub(crate) enum DataPayload {
71 Home {
72 projects: Vec<Project>,
73 stats: CreatorStats,
74 },
75 ProjectItems {
76 items: Vec<Item>,
77 },
78 StagedFiles {
79 files: Vec<StagedFile>,
80 storage: Option<StorageInfo>,
81 },
82 PublishResult {
83 filename: String,
84 success: bool,
85 error: Option<String>,
86 },
87 ItemDetail {
88 detail: ItemDetail,
89 versions: Vec<Version>,
90 },
91 ItemUpdated {
92 detail: ItemDetail,
93 },
94 ItemDeleted,
95 ItemActionError {
96 error: String,
97 },
98 /// Signal to reload the project items list after a mutation.
99 #[allow(dead_code)]
100 ProjectReload {
101 project_idx: usize,
102 },
103 BlogPosts {
104 posts: Vec<BlogPost>,
105 },
106 BlogCreated,
107 PromoCodes {
108 codes: Vec<PromoCode>,
109 },
110 LicenseKeys {
111 keys: Vec<LicenseKey>,
112 },
113 GenericSuccess {
114 message: String,
115 },
116 GenericError {
117 error: String,
118 },
119 Analytics {
120 data: AnalyticsData,
121 },
122 Transactions {
123 txs: Vec<Transaction>,
124 },
125 ExportCsv {
126 csv: String,
127 row_count: usize,
128 },
129 Settings {
130 keys: Vec<SshKeyInfo>,
131 storage: Option<StorageInfo>,
132 },
133 ItemTags {
134 tags: Vec<TagInfo>,
135 },
136 TagSearchResults {
137 results: Vec<TagInfo>,
138 },
139 CollectionsList {
140 collections: Vec<CollectionInfo>,
141 },
142 TiersList {
143 tiers: Vec<TierInfo>,
144 },
145 BulkActionComplete {
146 message: String,
147 },
148 }
149
150 /// Handle for sending events to a running TUI session.
151 #[derive(Clone)]
152 pub(crate) struct AppHandle {
153 tx: mpsc::Sender<AppEvent>,
154 }
155
156 impl AppHandle {
157 pub(crate) async fn send_input(&self, data: &[u8]) {
158 let _ = self.tx.send(AppEvent::Input(data.to_vec())).await;
159 }
160
161 pub(crate) async fn send_resize(&self, cols: u16, rows: u16) {
162 let _ = self.tx.send(AppEvent::Resize(cols, rows)).await;
163 }
164 }
165
166 /// Active screen in the TUI.
167 enum Screen {
168 Home,
169 /// Project detail view. Index is into `app.projects`.
170 Project(usize),
171 /// Upload management screen.
172 Upload,
173 /// Item detail view. Stores (project_index, item_id).
174 Item(usize, String),
175 /// Blog post list for a project. Stores (project_index, project_id).
176 Blog(usize, String),
177 /// Promo code management.
178 Promo,
179 /// License key management for an item. Stores (project_index, item_id).
180 Keys(usize, String),
181 /// Analytics dashboard.
182 Analytics,
183 /// Settings screen (profile, storage, SSH keys).
184 Settings,
185 /// Collections management.
186 Collections,
187 /// Subscription tiers for a project. Stores (project_index, project_id).
188 /// The id is carried for the tier-mutation calls the screen will need; the
189 /// render path only uses the index today.
190 Tiers(usize, #[allow(dead_code)] String),
191 }
192
193 /// User-editable metadata for a staged file.
194 #[derive(Debug, Clone, Default)]
195 pub(crate) struct FileMetadata {
196 pub title: Option<String>,
197 pub project_idx: Option<usize>,
198 pub project_name: Option<String>,
199 pub price_cents: i32,
200 }
201
202 /// Which field is being edited on the upload screen.
203 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
204 pub(crate) enum EditField {
205 Title,
206 Project,
207 Price,
208 }
209
210 /// Steps for creating a blog post.
211 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
212 pub(crate) enum BlogCreateStep {
213 Title,
214 Body,
215 /// Optional scheduling step — enter datetime or leave empty to publish as draft.
216 Schedule,
217 }
218
219 /// Steps for creating a promo code.
220 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
221 pub(crate) enum PromoCreateStep {
222 Code,
223 Discount,
224 }
225
226 /// Pending destructive action awaiting confirmation.
227 #[derive(Debug, Clone)]
228 pub(crate) enum ConfirmAction {
229 DeleteItem,
230 DeleteBlogPost { post_idx: usize },
231 DeletePromoCode { code_idx: usize },
232 RevokeLicenseKey { key_idx: usize },
233 BulkPublish { count: usize },
234 BulkUnpublish { count: usize },
235 BulkDelete { count: usize },
236 }
237
238 /// Application state shared across screens.
239 pub(crate) struct App {
240 pub user: UserInfo,
241 /// The colours every screen draws in, resolved once per session from the
242 /// creator's selection and their terminal's capabilities.
243 ///
244 /// Held for the life of the session rather than re-resolved per frame, and
245 /// `Copy`, so a screen reads it without a clone. Re-theming mid-session
246 /// would mean rebuilding this and is not something any surface offers yet.
247 pub theme: makeover_tui::Theme,
248 pub projects: Vec<Project>,
249 pub stats: Option<CreatorStats>,
250 pub items: Vec<Item>,
251 pub selected_index: usize,
252 pub selected_items: HashSet<usize>,
253 pub loading: bool,
254 pub staged_files: Vec<StagedFile>,
255 pub storage_info: Option<StorageInfo>,
256 pub file_metadata: Vec<FileMetadata>,
257 pub upload_status: Option<String>,
258 pub editing_field: Option<EditField>,
259 pub edit_buffer: String,
260 pub publishing: bool,
261 pub item_detail: Option<ItemDetail>,
262 pub item_versions: Vec<Version>,
263 pub item_status: Option<String>,
264 pub item_editing: Option<item::ItemEditField>,
265 // Blog
266 pub blog_posts: Vec<BlogPost>,
267 pub blog_project_title: Option<String>,
268 pub blog_status: Option<String>,
269 pub blog_creating: bool,
270 pub blog_create_step: Option<BlogCreateStep>,
271 pub blog_create_title: String,
272 pub blog_create_body: String,
273 // Promo codes
274 pub promo_codes: Vec<PromoCode>,
275 pub promo_status: Option<String>,
276 pub promo_editing_step: Option<PromoCreateStep>,
277 pub promo_create_code: String,
278 pub promo_create_discount: String,
279 // License keys
280 pub license_keys: Vec<LicenseKey>,
281 pub keys_item_title: Option<String>,
282 pub keys_status: Option<String>,
283 // Analytics
284 pub analytics_data: Option<AnalyticsData>,
285 pub analytics_range: String,
286 pub analytics_status: Option<String>,
287 pub analytics_show_transactions: bool,
288 pub transactions: Vec<Transaction>,
289 // Settings
290 pub ssh_keys: Vec<SshKeyInfo>,
291 pub settings_status: Option<String>,
292 // Tags (on item detail)
293 pub item_tags: Vec<TagInfo>,
294 pub tag_search_results: Vec<TagInfo>,
295 pub tag_searching: bool,
296 // Collections
297 pub collections: Vec<CollectionInfo>,
298 pub collections_status: Option<String>,
299 // Tiers
300 pub tiers: Vec<TierInfo>,
301 pub tiers_project_title: Option<String>,
302 pub tiers_status: Option<String>,
303 // Confirmation dialog
304 pub confirm_action: Option<ConfirmAction>,
305 }
306
307 impl App {
308 /// The viewer's own settlement currency.
309 ///
310 /// The right currency for every amount the server sends without one: this
311 /// creator's prices, their period totals, their transactions. It is *not*
312 /// right for per-project revenue, which carries its own currency because a
313 /// revenue split is paid in the currency of the project that earned it.
314 pub(crate) fn currency(&self) -> Currency {
315 self.user.settlement_currency
316 }
317
318 fn new(user: UserInfo, theme: makeover_tui::Theme) -> Self {
319 Self {
320 user,
321 theme,
322 projects: Vec::new(),
323 stats: None,
324 items: Vec::new(),
325 selected_index: 0,
326 selected_items: HashSet::new(),
327 loading: true,
328 staged_files: Vec::new(),
329 storage_info: None,
330 file_metadata: Vec::new(),
331 upload_status: None,
332 editing_field: None,
333 edit_buffer: String::new(),
334 publishing: false,
335 item_detail: None,
336 item_versions: Vec::new(),
337 item_status: None,
338 item_editing: None,
339 blog_posts: Vec::new(),
340 blog_project_title: None,
341 blog_status: None,
342 blog_creating: false,
343 blog_create_step: None,
344 blog_create_title: String::new(),
345 blog_create_body: String::new(),
346 promo_codes: Vec::new(),
347 promo_status: None,
348 promo_editing_step: None,
349 promo_create_code: String::new(),
350 promo_create_discount: String::new(),
351 license_keys: Vec::new(),
352 keys_item_title: None,
353 keys_status: None,
354 analytics_data: None,
355 analytics_range: "30d".to_string(),
356 analytics_status: None,
357 analytics_show_transactions: false,
358 transactions: Vec::new(),
359 ssh_keys: Vec::new(),
360 settings_status: None,
361 item_tags: Vec::new(),
362 tag_search_results: Vec::new(),
363 tag_searching: false,
364 collections: Vec::new(),
365 collections_status: None,
366 tiers: Vec::new(),
367 tiers_project_title: None,
368 tiers_status: None,
369 confirm_action: None,
370 }
371 }
372
373 fn list_len(&self, screen: &Screen) -> usize {
374 match screen {
375 Screen::Home => self.projects.len(),
376 Screen::Project(_) => self.items.len(),
377 Screen::Upload => self.staged_files.len(),
378 Screen::Item(..) => self.item_versions.len(),
379 Screen::Blog(..) => self.blog_posts.len(),
380 Screen::Promo => self.promo_codes.len(),
381 Screen::Keys(..) => self.license_keys.len(),
382 Screen::Analytics => self.transactions.len(),
383 Screen::Settings => self.ssh_keys.len(),
384 Screen::Collections => self.collections.len(),
385 Screen::Tiers(..) => self.tiers.len(),
386 }
387 }
388
389 fn move_up(&mut self, screen: &Screen) {
390 if self.selected_index > 0 {
391 self.selected_index -= 1;
392 } else {
393 // Wrap to bottom
394 let len = self.list_len(screen);
395 if len > 0 {
396 self.selected_index = len - 1;
397 }
398 }
399 }
400
401 fn move_down(&mut self, screen: &Screen) {
402 let len = self.list_len(screen);
403 if len > 0 {
404 if self.selected_index < len - 1 {
405 self.selected_index += 1;
406 } else {
407 // Wrap to top
408 self.selected_index = 0;
409 }
410 }
411 }
412
413 /// Ensure file_metadata vec matches staged_files length.
414 fn sync_metadata(&mut self) {
415 while self.file_metadata.len() < self.staged_files.len() {
416 let idx = self.file_metadata.len();
417 let title = staging::derive_title(&self.staged_files[idx].filename);
418 self.file_metadata.push(FileMetadata {
419 title: Some(title),
420 ..Default::default()
421 });
422 }
423 self.file_metadata.truncate(self.staged_files.len());
424 }
425 }
426
427 fn format_edit_prompt(field: EditField, buffer: &str) -> String {
428 let field_name = match field {
429 EditField::Title => "Title",
430 EditField::Project => "Project #",
431 EditField::Price => "Price ($)",
432 };
433 format!("{field_name}: {buffer}_")
434 }
435
436 // NOTE: parse_price, parse_key, and tests are below.
437 // All handle_*_input functions are in input.rs.
438 // All load_* functions and publish_file are in loading.rs.
439
440 fn parse_price(input: &str) -> i32 {
441 // Accept "5", "5.00", "5.99", "0" etc.
442 if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") {
443 return 0;
444 }
445 if let Some((dollars, cents)) = input.split_once('.') {
446 let d: i32 = dollars.parse().unwrap_or(0);
447 let cents_str = cents.get(..2).unwrap_or(cents);
448 let c: i32 = if cents_str.len() == 1 {
449 cents_str.parse::<i32>().unwrap_or(0) * 10
450 } else {
451 cents_str.parse().unwrap_or(0)
452 };
453 d * 100 + c
454 } else {
455 input.parse::<i32>().unwrap_or(0) * 100
456 }
457 }
458
459 /// Parse raw SSH input bytes into a crossterm KeyEvent.
460 fn parse_key(data: &[u8]) -> Option<KeyEvent> {
461 match data {
462 // Ctrl+C
463 [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
464 // Escape
465 [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
466 // Enter
467 [13 | 10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
468 // Tab
469 [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
470 // Backspace
471 [127 | 8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
472 // Arrow keys
473 [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
474 [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
475 [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
476 [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
477 // Single printable ASCII byte
478 [b] if b.is_ascii_graphic() || *b == b' ' => {
479 Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE))
480 }
481 // Ctrl+letter (1-26 maps to a-z)
482 [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new(
483 KeyCode::Char((b + b'a' - 1) as char),
484 KeyModifiers::CONTROL,
485 )),
486 _ => None,
487 }
488 }
489
490 #[cfg(test)]
491 mod tests {
492 use super::*;
493
494 /// An app with no data loaded, on a known theme.
495 fn app_on_a_fixed_theme() -> App {
496 App::new(
497 UserInfo {
498 user_id: "u1".to_string(),
499 username: "creator".to_string(),
500 display_name: None,
501 creator_tier: Some("small_files".to_string()),
502 can_create_projects: true,
503 suspended: false,
504 actor_token: String::new(),
505 settlement_currency: Currency::default(),
506 theme_id: None,
507 },
508 theme::tests::fixed(),
509 )
510 }
511
512 /// Every colour a rendered frame can put on screen, from one theme.
513 ///
514 /// `Color::Reset` is in the set because that is what an unstyled cell holds,
515 /// and `paint_page` has already given the whole viewport the theme's own
516 /// page colours underneath it.
517 fn colors_of(theme: &makeover_tui::Theme) -> Vec<ratatui::style::Color> {
518 let mut colors = vec![
519 ratatui::style::Color::Reset,
520 theme.surface_page,
521 theme.surface_raised,
522 theme.surface_sunken,
523 theme.surface_overlay,
524 theme.content_primary,
525 theme.content_secondary,
526 theme.content_muted,
527 theme.action_primary,
528 theme.status_danger,
529 theme.status_success,
530 theme.status_warning,
531 theme.status_info,
532 theme.line_border,
533 theme.border_strong,
534 theme.bevel_light,
535 theme.bevel_dark,
536 ];
537 colors.extend(theme.surface_well);
538 colors.extend(theme.category);
539 colors
540 }
541
542 /// An app with one project in it, so a screen renders a table rather than
543 /// its empty state.
544 fn app_with_a_project() -> App {
545 let mut app = app_on_a_fixed_theme();
546 app.loading = false;
547 app.projects = vec![Project {
548 id: "p1".to_string(),
549 slug: "field-recordings".to_string(),
550 title: "Field Recordings".to_string(),
551 project_type: "sample_pack".to_string(),
552 is_public: true,
553 item_count: 12,
554 revenue_cents: 4200,
555 currency: Currency::default(),
556 revenue_cents_by_currency: std::collections::BTreeMap::new(),
557 }];
558 app
559 }
560
561 /// What the home screen draws at a given width.
562 fn home_at(width: u16) -> String {
563 let app = app_with_a_project();
564 let backend = ratatui::backend::TestBackend::new(width, 12);
565 let mut terminal = Terminal::new(backend).expect("test backend");
566 terminal
567 .draw(|frame| {
568 run::paint_page(frame, &app);
569 home::render(frame, &app);
570 })
571 .expect("home renders");
572
573 terminal
574 .backend()
575 .buffer()
576 .content()
577 .iter()
578 .map(ratatui::buffer::Cell::symbol)
579 .collect()
580 }
581
582 /// A narrow window drops the columns that can be spared and keeps the one
583 /// that says which row this is.
584 ///
585 /// The screens narrowed at no width before `makeover-tui`'s table: every
586 /// one of them handed ratatui a hand-written `Constraint` list and
587 /// overflowed. What drops is a property of the column now, so this asserts
588 /// against the headings rather than against positions -- inserting a column
589 /// left of the cut is exactly the bug `Priority` exists to prevent.
590 #[test]
591 fn the_project_table_sheds_columns_before_it_sheds_the_title() {
592 let wide = home_at(120);
593 assert!(wide.contains("Title"), "the title heading is always drawn");
594 assert!(wide.contains("Items"), "a wide window keeps every column");
595
596 let narrow = home_at(34);
597 assert!(
598 narrow.contains("Title"),
599 "the essential column survives every width",
600 );
601 assert!(
602 !narrow.contains("Items"),
603 "an optional column drops before an essential one",
604 );
605 }
606
607 /// No screen may paint a colour that is not in the theme.
608 ///
609 /// This is the guard the port exists to make possible, and the terminal's
610 /// equivalent of the frontend lint the web surfaces carry: a `Color::Red`
611 /// added back to a screen in six months fails here rather than being noticed
612 /// by a creator whose theme has no red in it. Asserted over the rendered
613 /// buffer rather than by grepping the source, so it also catches a literal
614 /// arriving through a widget's own default.
615 #[test]
616 fn every_rendered_colour_comes_from_the_theme() {
617 // With a project in it, so the table's own tones are on the buffer this
618 // reads. An empty screen renders its empty state and asserts nothing
619 // about the thing most likely to name a colour.
620 let app = app_with_a_project();
621 let permitted = colors_of(&app.theme);
622
623 let backend = ratatui::backend::TestBackend::new(120, 40);
624 let mut terminal = Terminal::new(backend).expect("test backend");
625 terminal
626 .draw(|frame| {
627 run::paint_page(frame, &app);
628 home::render(frame, &app);
629 })
630 .expect("home renders");
631
632 for cell in terminal.backend().buffer().content() {
633 assert!(
634 permitted.contains(&cell.fg),
635 "foreground {:?} is in no theme intent",
636 cell.fg,
637 );
638 assert!(
639 permitted.contains(&cell.bg),
640 "background {:?} is in no theme intent",
641 cell.bg,
642 );
643 }
644 }
645
646 // The page is painted, not left to the terminal's default. A transparent
647 // background is what the port set out to remove, and it looks identical to
648 // a correct one on a terminal whose own colours happen to match.
649 #[test]
650 fn the_page_surface_is_painted_under_every_screen() {
651 let app = app_on_a_fixed_theme();
652
653 let backend = ratatui::backend::TestBackend::new(40, 10);
654 let mut terminal = Terminal::new(backend).expect("test backend");
655 terminal
656 .draw(|frame| run::paint_page(frame, &app))
657 .expect("the page paints");
658
659 for cell in terminal.backend().buffer().content() {
660 assert_eq!(cell.bg, app.theme.surface_page);
661 }
662 }
663
664 #[test]
665 fn parse_price_whole_dollars() {
666 assert_eq!(parse_price("5"), 500);
667 assert_eq!(parse_price("10"), 1000);
668 }
669
670 #[test]
671 fn parse_price_with_cents() {
672 assert_eq!(parse_price("5.99"), 599);
673 assert_eq!(parse_price("0.50"), 50);
674 }
675
676 #[test]
677 fn parse_price_single_digit_cents() {
678 assert_eq!(parse_price("5.5"), 550);
679 assert_eq!(parse_price("1.1"), 110);
680 }
681
682 #[test]
683 fn parse_price_free() {
684 assert_eq!(parse_price("0"), 0);
685 assert_eq!(parse_price("free"), 0);
686 assert_eq!(parse_price("FREE"), 0);
687 assert_eq!(parse_price(""), 0);
688 }
689
690 #[test]
691 fn parse_price_truncates_extra_decimals() {
692 assert_eq!(parse_price("5.999"), 599);
693 }
694
695 #[test]
696 fn parse_key_ctrl_c() {
697 let key = parse_key(&[3]).unwrap();
698 assert_eq!(key.code, KeyCode::Char('c'));
699 assert!(key.modifiers.contains(KeyModifiers::CONTROL));
700 }
701
702 #[test]
703 fn parse_key_enter() {
704 let key = parse_key(&[13]).unwrap();
705 assert_eq!(key.code, KeyCode::Enter);
706 }
707
708 #[test]
709 fn parse_key_arrow_up() {
710 let key = parse_key(&[27, 91, 65]).unwrap();
711 assert_eq!(key.code, KeyCode::Up);
712 }
713
714 #[test]
715 fn parse_key_printable_char() {
716 let key = parse_key(b"a").unwrap();
717 assert_eq!(key.code, KeyCode::Char('a'));
718 }
719
720 #[test]
721 fn parse_key_unknown() {
722 assert!(parse_key(&[27, 91, 100, 100]).is_none());
723 }
724 }
725