Skip to main content

max / mnw-cli

27.1 KB · 780 lines History Blame Raw
1 //! TUI application state and event loop.
2
3 pub mod analytics;
4 pub mod blog;
5 pub mod home;
6 mod input;
7 pub mod item;
8 pub mod keys;
9 mod loading;
10 pub mod project;
11 pub mod promo;
12 pub mod settings;
13 pub mod upload;
14 pub mod widgets;
15
16 use std::path::PathBuf;
17
18 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
19 use ratatui::Terminal;
20 use ratatui::backend::CrosstermBackend;
21 use tokio::sync::mpsc;
22
23 use crate::api::{
24 AnalyticsData, BlogPost, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project,
25 PromoCode, SshKeyInfo, StorageInfo, Transaction, UserInfo, Version,
26 };
27 use crate::ssh::terminal::TerminalHandle;
28 use crate::staging::{self, StagedFile};
29
30 use input::*;
31 use loading::*;
32
33 /// Events sent to the TUI event loop.
34 pub enum AppEvent {
35 /// Raw input bytes from the SSH channel.
36 Input(Vec<u8>),
37 /// Terminal resize.
38 Resize(u16, u16),
39 /// Data loaded from the API.
40 DataLoaded(DataPayload),
41 }
42
43 /// Payload variants for async data loading.
44 pub enum DataPayload {
45 Home {
46 projects: Vec<Project>,
47 stats: CreatorStats,
48 },
49 ProjectItems {
50 items: Vec<Item>,
51 },
52 StagedFiles {
53 files: Vec<StagedFile>,
54 storage: Option<StorageInfo>,
55 },
56 PublishResult {
57 filename: String,
58 success: bool,
59 error: Option<String>,
60 },
61 ItemDetail {
62 detail: ItemDetail,
63 versions: Vec<Version>,
64 },
65 ItemUpdated {
66 detail: ItemDetail,
67 },
68 ItemDeleted,
69 ItemActionError {
70 error: String,
71 },
72 /// Signal to reload the project items list after a mutation.
73 #[allow(dead_code)]
74 ProjectReload {
75 project_idx: usize,
76 },
77 BlogPosts {
78 posts: Vec<BlogPost>,
79 },
80 BlogCreated,
81 PromoCodes {
82 codes: Vec<PromoCode>,
83 },
84 LicenseKeys {
85 keys: Vec<LicenseKey>,
86 },
87 GenericSuccess {
88 message: String,
89 },
90 GenericError {
91 error: String,
92 },
93 Analytics {
94 data: AnalyticsData,
95 },
96 Transactions {
97 txs: Vec<Transaction>,
98 },
99 ExportCsv {
100 csv: String,
101 row_count: usize,
102 },
103 Settings {
104 keys: Vec<SshKeyInfo>,
105 storage: Option<StorageInfo>,
106 },
107 }
108
109 /// Handle for sending events to a running TUI session.
110 #[derive(Clone)]
111 pub struct AppHandle {
112 tx: mpsc::Sender<AppEvent>,
113 }
114
115 impl AppHandle {
116 pub async fn send_input(&self, data: &[u8]) {
117 let _ = self.tx.send(AppEvent::Input(data.to_vec())).await;
118 }
119
120 pub async fn send_resize(&self, cols: u16, rows: u16) {
121 let _ = self.tx.send(AppEvent::Resize(cols, rows)).await;
122 }
123 }
124
125 /// Active screen in the TUI.
126 enum Screen {
127 Home,
128 /// Project detail view. Index is into `app.projects`.
129 Project(usize),
130 /// Upload management screen.
131 Upload,
132 /// Item detail view. Stores (project_index, item_id).
133 Item(usize, String),
134 /// Blog post list for a project. Stores (project_index, project_id).
135 Blog(usize, String),
136 /// Promo code management.
137 Promo,
138 /// License key management for an item. Stores (project_index, item_id).
139 Keys(usize, String),
140 /// Analytics dashboard.
141 Analytics,
142 /// Settings screen (profile, storage, SSH keys).
143 Settings,
144 }
145
146 /// User-editable metadata for a staged file.
147 #[derive(Debug, Clone, Default)]
148 pub struct FileMetadata {
149 pub title: Option<String>,
150 pub project_idx: Option<usize>,
151 pub project_name: Option<String>,
152 pub price_cents: i32,
153 }
154
155 /// Which field is being edited on the upload screen.
156 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
157 pub(crate) enum EditField {
158 Title,
159 Project,
160 Price,
161 }
162
163 /// Steps for creating a blog post.
164 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
165 pub(crate) enum BlogCreateStep {
166 Title,
167 Body,
168 }
169
170 /// Steps for creating a promo code.
171 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
172 pub(crate) enum PromoCreateStep {
173 Code,
174 Discount,
175 }
176
177 /// Pending destructive action awaiting confirmation.
178 #[derive(Debug, Clone)]
179 pub(crate) enum ConfirmAction {
180 DeleteItem,
181 DeleteBlogPost { post_idx: usize },
182 DeletePromoCode { code_idx: usize },
183 RevokeLicenseKey { key_idx: usize },
184 }
185
186 /// Application state shared across screens.
187 pub struct App {
188 pub user: UserInfo,
189 pub projects: Vec<Project>,
190 pub stats: Option<CreatorStats>,
191 pub items: Vec<Item>,
192 pub selected_index: usize,
193 pub loading: bool,
194 pub staged_files: Vec<StagedFile>,
195 pub storage_info: Option<StorageInfo>,
196 pub file_metadata: Vec<FileMetadata>,
197 pub upload_status: Option<String>,
198 pub editing_field: Option<EditField>,
199 pub edit_buffer: String,
200 pub publishing: bool,
201 pub item_detail: Option<ItemDetail>,
202 pub item_versions: Vec<Version>,
203 pub item_status: Option<String>,
204 pub item_editing: Option<item::ItemEditField>,
205 // Blog
206 pub blog_posts: Vec<BlogPost>,
207 pub blog_project_title: Option<String>,
208 pub blog_status: Option<String>,
209 pub blog_creating: bool,
210 pub blog_create_step: Option<BlogCreateStep>,
211 pub blog_create_title: String,
212 // Promo codes
213 pub promo_codes: Vec<PromoCode>,
214 pub promo_status: Option<String>,
215 pub promo_editing_step: Option<PromoCreateStep>,
216 pub promo_create_code: String,
217 pub promo_create_discount: String,
218 // License keys
219 pub license_keys: Vec<LicenseKey>,
220 pub keys_item_title: Option<String>,
221 pub keys_status: Option<String>,
222 // Analytics
223 pub analytics_data: Option<AnalyticsData>,
224 pub analytics_range: String,
225 pub analytics_status: Option<String>,
226 pub analytics_show_transactions: bool,
227 pub transactions: Vec<Transaction>,
228 // Settings
229 pub ssh_keys: Vec<SshKeyInfo>,
230 pub settings_status: Option<String>,
231 // Confirmation dialog
232 pub confirm_action: Option<ConfirmAction>,
233 }
234
235 impl App {
236 fn new(user: UserInfo) -> Self {
237 Self {
238 user,
239 projects: Vec::new(),
240 stats: None,
241 items: Vec::new(),
242 selected_index: 0,
243 loading: true,
244 staged_files: Vec::new(),
245 storage_info: None,
246 file_metadata: Vec::new(),
247 upload_status: None,
248 editing_field: None,
249 edit_buffer: String::new(),
250 publishing: false,
251 item_detail: None,
252 item_versions: Vec::new(),
253 item_status: None,
254 item_editing: None,
255 blog_posts: Vec::new(),
256 blog_project_title: None,
257 blog_status: None,
258 blog_creating: false,
259 blog_create_step: None,
260 blog_create_title: String::new(),
261 promo_codes: Vec::new(),
262 promo_status: None,
263 promo_editing_step: None,
264 promo_create_code: String::new(),
265 promo_create_discount: String::new(),
266 license_keys: Vec::new(),
267 keys_item_title: None,
268 keys_status: None,
269 analytics_data: None,
270 analytics_range: "30d".to_string(),
271 analytics_status: None,
272 analytics_show_transactions: false,
273 transactions: Vec::new(),
274 ssh_keys: Vec::new(),
275 settings_status: None,
276 confirm_action: None,
277 }
278 }
279
280 fn list_len(&self, screen: &Screen) -> usize {
281 match screen {
282 Screen::Home => self.projects.len(),
283 Screen::Project(_) => self.items.len(),
284 Screen::Upload => self.staged_files.len(),
285 Screen::Item(..) => self.item_versions.len(),
286 Screen::Blog(..) => self.blog_posts.len(),
287 Screen::Promo => self.promo_codes.len(),
288 Screen::Keys(..) => self.license_keys.len(),
289 Screen::Analytics => self.transactions.len(),
290 Screen::Settings => self.ssh_keys.len(),
291 }
292 }
293
294 fn move_up(&mut self, screen: &Screen) {
295 if self.selected_index > 0 {
296 self.selected_index -= 1;
297 } else {
298 // Wrap to bottom
299 let len = self.list_len(screen);
300 if len > 0 {
301 self.selected_index = len - 1;
302 }
303 }
304 }
305
306 fn move_down(&mut self, screen: &Screen) {
307 let len = self.list_len(screen);
308 if len > 0 {
309 if self.selected_index < len - 1 {
310 self.selected_index += 1;
311 } else {
312 // Wrap to top
313 self.selected_index = 0;
314 }
315 }
316 }
317
318 /// Ensure file_metadata vec matches staged_files length.
319 fn sync_metadata(&mut self) {
320 while self.file_metadata.len() < self.staged_files.len() {
321 let idx = self.file_metadata.len();
322 let title = staging::derive_title(&self.staged_files[idx].filename);
323 self.file_metadata.push(FileMetadata {
324 title: Some(title),
325 ..Default::default()
326 });
327 }
328 self.file_metadata.truncate(self.staged_files.len());
329 }
330 }
331
332 /// Launch the TUI event loop in a background task.
333 #[allow(clippy::too_many_arguments)]
334 pub fn launch(
335 writer: TerminalHandle,
336 user: UserInfo,
337 cols: u16,
338 rows: u16,
339 session_handle: russh::server::Handle,
340 channel_id: russh::ChannelId,
341 api: MnwApiClient,
342 staging_dir: PathBuf,
343 ) -> anyhow::Result<AppHandle> {
344 let backend = CrosstermBackend::new(writer);
345 let mut terminal = Terminal::new(backend)?;
346 terminal.resize(ratatui::layout::Rect::new(0, 0, cols, rows))?;
347
348 let (tx, mut rx) = mpsc::channel::<AppEvent>(64);
349 let handle = AppHandle { tx: tx.clone() };
350
351 // Kick off initial data load
352 let user_id = user.user_id.clone();
353 let api_clone = api.clone();
354 let tx_clone = tx.clone();
355 tokio::spawn(async move {
356 load_home_data(&api_clone, &user_id, &tx_clone).await;
357 });
358
359 tokio::spawn(async move {
360 let mut app = App::new(user);
361 let mut screen = Screen::Home;
362 let staging_dir = staging_dir;
363
364 // Initial render (loading state)
365 if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
366 tracing::error!(error = ?e, "initial render failed");
367 return;
368 }
369
370 while let Some(event) = rx.recv().await {
371 match event {
372 AppEvent::Input(data) => {
373 if let Some(key) = parse_key(&data) {
374 // Global quit
375 if (key.modifiers.contains(KeyModifiers::CONTROL)
376 && key.code == KeyCode::Char('c'))
377 || matches!(
378 (&screen, key.code),
379 (Screen::Home, KeyCode::Char('q') | KeyCode::Char('Q'))
380 )
381 {
382 tracing::info!(user = %app.user.username, "user quit");
383 let _ = session_handle.close(channel_id).await;
384 return;
385 }
386
387 match screen {
388 Screen::Home => {
389 handle_home_input(
390 key, &mut app, &mut screen, &api, &tx, &staging_dir,
391 )
392 .await;
393 }
394 Screen::Project(_) => {
395 handle_project_input(
396 key, &mut app, &mut screen, &api, &tx,
397 )
398 .await;
399 }
400 Screen::Upload => {
401 handle_upload_input(
402 key, &mut app, &mut screen, &api, &tx, &staging_dir,
403 )
404 .await;
405 }
406 Screen::Item(..) => {
407 handle_item_input(
408 key, &mut app, &mut screen, &api, &tx,
409 )
410 .await;
411 }
412 Screen::Blog(..) => {
413 handle_blog_input(
414 key, &mut app, &mut screen, &api, &tx,
415 )
416 .await;
417 }
418 Screen::Promo => {
419 handle_promo_input(
420 key, &mut app, &mut screen, &api, &tx,
421 )
422 .await;
423 }
424 Screen::Keys(..) => {
425 handle_keys_input(
426 key, &mut app, &mut screen, &api, &tx,
427 )
428 .await;
429 }
430 Screen::Analytics => {
431 handle_analytics_input(
432 key, &mut app, &mut screen, &api, &tx,
433 )
434 .await;
435 }
436 Screen::Settings => {
437 handle_settings_input(
438 key, &mut app, &mut screen, &api, &tx,
439 )
440 .await;
441 }
442 }
443 }
444 }
445 AppEvent::Resize(cols, rows) => {
446 let rect = ratatui::layout::Rect::new(0, 0, cols, rows);
447 let _ = terminal.resize(rect);
448 }
449 AppEvent::DataLoaded(payload) => match payload {
450 DataPayload::Home { projects, stats } => {
451 app.projects = projects;
452 app.stats = Some(stats);
453 app.loading = false;
454 app.selected_index = 0;
455 }
456 DataPayload::ProjectItems { items } => {
457 app.items = items;
458 app.loading = false;
459 app.selected_index = 0;
460 }
461 DataPayload::StagedFiles { files, storage } => {
462 app.staged_files = files;
463 if let Some(s) = storage {
464 app.storage_info = Some(s);
465 }
466 app.sync_metadata();
467 app.loading = false;
468 if app.selected_index >= app.staged_files.len() && !app.staged_files.is_empty() {
469 app.selected_index = app.staged_files.len() - 1;
470 }
471 }
472 DataPayload::ItemDetail { detail, versions } => {
473 app.item_detail = Some(detail);
474 app.item_versions = versions;
475 app.loading = false;
476 app.selected_index = 0;
477 }
478 DataPayload::ItemUpdated { detail } => {
479 app.item_detail = Some(detail);
480 app.item_status = Some("Updated".to_string());
481 app.item_editing = None;
482 app.edit_buffer.clear();
483 }
484 DataPayload::ItemDeleted => {
485 app.item_status = Some("Deleted".to_string());
486 // Navigate back to project view
487 if let Screen::Item(project_idx, _) = &screen {
488 let pidx = *project_idx;
489 screen = Screen::Project(pidx);
490 app.item_detail = None;
491 app.item_versions.clear();
492 app.item_status = None;
493 app.selected_index = 0;
494 app.loading = true;
495
496 if let Some(p) = app.projects.get(pidx) {
497 let api = api.clone();
498 let project_id = p.id.clone();
499 let user_id = app.user.user_id.clone();
500 let tx = tx.clone();
501 tokio::spawn(async move {
502 load_project_items(&api, &project_id, &user_id, &tx).await;
503 });
504 }
505 }
506 }
507 DataPayload::ItemActionError { error } => {
508 app.item_status = Some(format!("Error: {}", error));
509 }
510 DataPayload::BlogPosts { posts } => {
511 app.blog_posts = posts;
512 app.loading = false;
513 app.selected_index = 0;
514 }
515 DataPayload::BlogCreated => {
516 app.blog_creating = false;
517 app.blog_create_step = None;
518 app.blog_create_title.clear();
519 app.edit_buffer.clear();
520 app.blog_status = Some("Post created".to_string());
521 // Reload blog posts
522 if let Screen::Blog(_, ref project_id) = screen {
523 let api = api.clone();
524 let user_id = app.user.user_id.clone();
525 let project_id = project_id.clone();
526 let tx = tx.clone();
527 tokio::spawn(async move {
528 load_blog_posts(&api, &user_id, &project_id, &tx).await;
529 });
530 }
531 }
532 DataPayload::PromoCodes { codes } => {
533 app.promo_codes = codes;
534 app.loading = false;
535 app.selected_index = 0;
536 }
537 DataPayload::LicenseKeys { keys: k } => {
538 app.license_keys = k;
539 app.loading = false;
540 app.selected_index = 0;
541 }
542 DataPayload::GenericSuccess { message } => {
543 // Use as status on whatever screen is active
544 match screen {
545 Screen::Blog(..) => app.blog_status = Some(message),
546 Screen::Promo => app.promo_status = Some(message),
547 Screen::Keys(..) => app.keys_status = Some(message),
548 _ => {}
549 }
550 }
551 DataPayload::GenericError { error } => {
552 let msg = format!("Error: {}", error);
553 match screen {
554 Screen::Blog(..) => app.blog_status = Some(msg),
555 Screen::Promo => {
556 app.promo_status = Some(msg);
557 app.promo_editing_step = None;
558 }
559 Screen::Keys(..) => app.keys_status = Some(msg),
560 Screen::Analytics => app.analytics_status = Some(msg),
561 Screen::Settings => app.settings_status = Some(msg),
562 _ => app.item_status = Some(msg),
563 }
564 }
565 DataPayload::Analytics { data } => {
566 app.analytics_data = Some(data);
567 app.loading = false;
568 }
569 DataPayload::Transactions { txs } => {
570 app.transactions = txs;
571 app.loading = false;
572 app.selected_index = 0;
573 }
574 DataPayload::ExportCsv { csv, row_count } => {
575 app.analytics_status =
576 Some(format!("Exported {} rows ({} bytes)", row_count, csv.len()));
577 }
578 DataPayload::Settings { keys, storage } => {
579 app.ssh_keys = keys;
580 if let Some(s) = storage {
581 app.storage_info = Some(s);
582 }
583 app.loading = false;
584 app.selected_index = 0;
585 }
586 DataPayload::ProjectReload { project_idx } => {
587 if let Some(p) = app.projects.get(project_idx) {
588 app.loading = true;
589 let api = api.clone();
590 let project_id = p.id.clone();
591 let user_id = app.user.user_id.clone();
592 let tx = tx.clone();
593 tokio::spawn(async move {
594 load_project_items(&api, &project_id, &user_id, &tx).await;
595 });
596 }
597 }
598 DataPayload::PublishResult {
599 filename,
600 success,
601 error,
602 } => {
603 app.publishing = false;
604 if success {
605 app.upload_status = Some(format!("Published {}", filename));
606 // Reload staged files
607 let staging_dir = staging_dir.clone();
608 let api = api.clone();
609 let user_id = app.user.user_id.clone();
610 let tx = tx.clone();
611 tokio::spawn(async move {
612 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
613 });
614 } else {
615 app.upload_status = Some(format!(
616 "Error: {}",
617 error.unwrap_or_else(|| "unknown error".to_string())
618 ));
619 }
620 }
621 },
622 }
623
624 // Re-render after every event
625 if let Err(e) = terminal.draw(|frame| match &screen {
626 Screen::Home => home::render(frame, &app),
627 Screen::Project(idx) => {
628 if let Some(p) = app.projects.get(*idx) {
629 project::render(frame, &app, p);
630 } else {
631 home::render(frame, &app);
632 }
633 }
634 Screen::Upload => upload::render(frame, &app),
635 Screen::Item(..) => item::render(frame, &app),
636 Screen::Blog(..) => blog::render(frame, &app),
637 Screen::Promo => promo::render(frame, &app),
638 Screen::Keys(..) => keys::render(frame, &app),
639 Screen::Analytics => analytics::render(frame, &app),
640 Screen::Settings => settings::render(frame, &app),
641 }) {
642 tracing::error!(error = ?e, "render failed");
643 return;
644 }
645 }
646 });
647
648 Ok(handle)
649 }
650
651 fn format_edit_prompt(field: EditField, buffer: &str) -> String {
652 let field_name = match field {
653 EditField::Title => "Title",
654 EditField::Project => "Project #",
655 EditField::Price => "Price ($)",
656 };
657 format!("{}: {}_", field_name, buffer)
658 }
659
660 // NOTE: parse_price, parse_key, and tests are below.
661 // All handle_*_input functions are in input.rs.
662 // All load_* functions and publish_file are in loading.rs.
663
664 fn parse_price(input: &str) -> i32 {
665 // Accept "5", "5.00", "5.99", "0" etc.
666 if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") {
667 return 0;
668 }
669 if let Some((dollars, cents)) = input.split_once('.') {
670 let d: i32 = dollars.parse().unwrap_or(0);
671 let cents_str = cents.get(..2).unwrap_or(cents);
672 let c: i32 = if cents_str.len() == 1 {
673 cents_str.parse::<i32>().unwrap_or(0) * 10
674 } else {
675 cents_str.parse().unwrap_or(0)
676 };
677 d * 100 + c
678 } else {
679 input.parse::<i32>().unwrap_or(0) * 100
680 }
681 }
682
683 /// Parse raw SSH input bytes into a crossterm KeyEvent.
684 fn parse_key(data: &[u8]) -> Option<KeyEvent> {
685 match data {
686 // Ctrl+C
687 [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
688 // Escape
689 [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
690 // Enter
691 [13] | [10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
692 // Tab
693 [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
694 // Backspace
695 [127] | [8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
696 // Arrow keys
697 [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
698 [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
699 [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
700 [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
701 // Single printable ASCII byte
702 [b] if b.is_ascii_graphic() || *b == b' ' => {
703 Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE))
704 }
705 // Ctrl+letter (1-26 maps to a-z)
706 [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new(
707 KeyCode::Char((b + b'a' - 1) as char),
708 KeyModifiers::CONTROL,
709 )),
710 _ => None,
711 }
712 }
713
714
715 #[cfg(test)]
716 mod tests {
717 use super::*;
718
719 #[test]
720 fn parse_price_whole_dollars() {
721 assert_eq!(parse_price("5"), 500);
722 assert_eq!(parse_price("10"), 1000);
723 }
724
725 #[test]
726 fn parse_price_with_cents() {
727 assert_eq!(parse_price("5.99"), 599);
728 assert_eq!(parse_price("0.50"), 50);
729 }
730
731 #[test]
732 fn parse_price_single_digit_cents() {
733 assert_eq!(parse_price("5.5"), 550);
734 assert_eq!(parse_price("1.1"), 110);
735 }
736
737 #[test]
738 fn parse_price_free() {
739 assert_eq!(parse_price("0"), 0);
740 assert_eq!(parse_price("free"), 0);
741 assert_eq!(parse_price("FREE"), 0);
742 assert_eq!(parse_price(""), 0);
743 }
744
745 #[test]
746 fn parse_price_truncates_extra_decimals() {
747 assert_eq!(parse_price("5.999"), 599);
748 }
749
750 #[test]
751 fn parse_key_ctrl_c() {
752 let key = parse_key(&[3]).unwrap();
753 assert_eq!(key.code, KeyCode::Char('c'));
754 assert!(key.modifiers.contains(KeyModifiers::CONTROL));
755 }
756
757 #[test]
758 fn parse_key_enter() {
759 let key = parse_key(&[13]).unwrap();
760 assert_eq!(key.code, KeyCode::Enter);
761 }
762
763 #[test]
764 fn parse_key_arrow_up() {
765 let key = parse_key(&[27, 91, 65]).unwrap();
766 assert_eq!(key.code, KeyCode::Up);
767 }
768
769 #[test]
770 fn parse_key_printable_char() {
771 let key = parse_key(&[b'a']).unwrap();
772 assert_eq!(key.code, KeyCode::Char('a'));
773 }
774
775 #[test]
776 fn parse_key_unknown() {
777 assert!(parse_key(&[27, 91, 100, 100]).is_none());
778 }
779 }
780