Skip to main content

max / makenotwork

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