diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 3ae894c..6745ea2 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -8,32 +8,43 @@ use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScree use ratatui::prelude::*; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; use tokio::task::JoinHandle; -use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; +use crate::models::{AppState, GpuItem, LeaderboardItem, SubmissionModeItem}; use crate::service; use crate::utils; +use crate::views::loading_page::{LoadingPage, LoadingPageState}; +use crate::views::result_page::{ResultPage, ResultPageState}; +#[derive(Default, Debug)] pub struct App { pub filepath: String, pub cli_id: String, + pub leaderboards: Vec, pub leaderboards_state: ListState, pub selected_leaderboard: Option, + pub gpus: Vec, pub gpus_state: ListState, pub selected_gpu: Option, + pub submission_modes: Vec, pub submission_modes_state: ListState, pub selected_submission_mode: Option, - pub modal_state: ModelState, + + pub app_state: AppState, pub final_status: Option, - pub loading_message: Option, + pub should_quit: bool, pub submission_task: Option>>, pub leaderboards_task: Option, anyhow::Error>>>, pub gpus_task: Option, anyhow::Error>>>, + + pub loading_page_state: LoadingPageState, + + pub result_page_state: ResultPageState, } impl App { @@ -64,52 +75,54 @@ impl App { let mut app = Self { filepath: filepath.as_ref().to_string_lossy().to_string(), cli_id, - leaderboards: Vec::new(), - leaderboards_state: ListState::default(), - selected_leaderboard: None, - gpus: Vec::new(), - gpus_state: ListState::default(), - selected_gpu: None, submission_modes, - submission_modes_state: ListState::default(), selected_submission_mode: None, - modal_state: ModelState::LeaderboardSelection, - final_status: None, - loading_message: None, - should_quit: false, - submission_task: None, - leaderboards_task: None, - gpus_task: None, + ..Default::default() }; - // Initialize list states app.leaderboards_state.select(Some(0)); app.gpus_state.select(Some(0)); app.submission_modes_state.select(Some(0)); - app } + pub fn update_loading_page_state(&mut self, terminal_width: u16) { + if self.app_state != AppState::WaitingForResult { + return; + } + + let st = &mut self.loading_page_state; + st.progress_column = { + if st.progress_column < terminal_width { + st.progress_column + 1 + } else { + st.loop_count += 1; + 0 + } + }; + st.progress_bar = f64::from(st.progress_column) * 100.0 / f64::from(terminal_width); + } + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { if !popcorn_directives.leaderboard_name.is_empty() { self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); if !popcorn_directives.gpus.is_empty() { self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::GpuSelection; + self.app_state = AppState::GpuSelection; } } else if !popcorn_directives.gpus.is_empty() { self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); if !popcorn_directives.leaderboard_name.is_empty() { self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::LeaderboardSelection; + self.app_state = AppState::LeaderboardSelection; } } else { - self.modal_state = ModelState::LeaderboardSelection; + self.app_state = AppState::LeaderboardSelection; } } @@ -120,26 +133,20 @@ impl App { return Ok(true); } - // Ignore other keys while loading - if self.loading_message.is_some() { - return Ok(false); - } - match key.code { KeyCode::Char('q') => { self.should_quit = true; return Ok(true); } - KeyCode::Enter => match self.modal_state { - ModelState::LeaderboardSelection => { + KeyCode::Enter => match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx < self.leaderboards.len() { self.selected_leaderboard = Some(self.leaderboards[idx].title_text.clone()); if self.selected_gpu.is_none() { - self.modal_state = ModelState::GpuSelection; - // Spawn GPU loading task + self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { self.set_error_and_quit(format!( "Error starting GPU fetch: {}", @@ -147,28 +154,27 @@ impl App { )); } } else { - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } return Ok(true); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx < self.gpus.len() { self.selected_gpu = Some(self.gpus[idx].title_text.clone()); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; return Ok(true); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx < self.submission_modes.len() { self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg - // Spawn the submission task + self.app_state = AppState::WaitingForResult; if let Err(e) = self.spawn_submit_solution() { self.set_error_and_quit(format!( "Error starting submission: {}", @@ -179,7 +185,7 @@ impl App { } } } - _ => {} // WaitingForResult state doesn't handle Enter + _ => {} }, KeyCode::Up => { self.move_selection_up(); @@ -189,36 +195,33 @@ impl App { self.move_selection_down(); return Ok(true); } - _ => {} // Ignore other keys + _ => {} } - Ok(false) } - // Helper to reduce repetition fn set_error_and_quit(&mut self, error_message: String) { self.final_status = Some(error_message); self.should_quit = true; - self.loading_message = None; // Clear loading on error } fn move_selection_up(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { + match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx > 0 { self.leaderboards_state.select(Some(idx - 1)); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx > 0 { self.gpus_state.select(Some(idx - 1)); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx > 0 { self.submission_modes_state.select(Some(idx - 1)); @@ -230,22 +233,22 @@ impl App { } fn move_selection_down(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { + match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx < self.leaderboards.len().saturating_sub(1) { self.leaderboards_state.select(Some(idx + 1)); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx < self.gpus.len().saturating_sub(1) { self.gpus_state.select(Some(idx + 1)); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx < self.submission_modes.len().saturating_sub(1) { self.submission_modes_state.select(Some(idx + 1)); @@ -261,7 +264,6 @@ impl App { self.leaderboards_task = Some(tokio::spawn(async move { service::fetch_leaderboards(&client).await })); - self.loading_message = Some("Loading leaderboards...".to_string()); Ok(()) } @@ -274,7 +276,6 @@ impl App { self.gpus_task = Some(tokio::spawn(async move { service::fetch_gpus(&client, &leaderboard_name).await })); - self.loading_message = Some("Loading GPUs...".to_string()); Ok(()) } @@ -303,7 +304,6 @@ impl App { service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) .await })); - self.loading_message = Some("Submitting solution...".to_string()); Ok(()) } @@ -314,7 +314,6 @@ impl App { match task.await { Ok(Ok(leaderboards)) => { self.leaderboards = leaderboards; - // If a leaderboard was pre-selected (e.g., from directives), try to find and select it if let Some(selected_name) = &self.selected_leaderboard { if let Some(index) = self .leaderboards @@ -322,32 +321,26 @@ impl App { .position(|lb| &lb.title_text == selected_name) { self.leaderboards_state.select(Some(index)); - // If GPU was also pre-selected, move to submission mode selection - // Otherwise, spawn GPU loading task if self.selected_gpu.is_some() { - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::GpuSelection; + self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { self.set_error_and_quit(format!( "Error starting GPU fetch: {}", e )); - return; // Exit early on error + return; } } } else { - // Pre-selected leaderboard not found, reset selection and state self.selected_leaderboard = None; - self.leaderboards_state.select(Some(0)); // Select first available - self.modal_state = ModelState::LeaderboardSelection; - // Stay here + self.leaderboards_state.select(Some(0)); + self.app_state = AppState::LeaderboardSelection; } } else { - self.leaderboards_state.select(Some(0)); // Select first if no pre-selection + self.leaderboards_state.select(Some(0)); } - - self.loading_message = None; } Ok(Err(e)) => { self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) @@ -365,7 +358,6 @@ impl App { match task.await { Ok(Ok(gpus)) => { self.gpus = gpus; - // If a GPU was pre-selected, try to find and select it if let Some(selected_name) = &self.selected_gpu { if let Some(index) = self .gpus @@ -373,19 +365,15 @@ impl App { .position(|gpu| &gpu.title_text == selected_name) { self.gpus_state.select(Some(index)); - self.modal_state = ModelState::SubmissionModeSelection; - // Move to next step + self.app_state = AppState::SubmissionModeSelection; } else { - // Pre-selected GPU not found, reset selection self.selected_gpu = None; - self.gpus_state.select(Some(0)); // Select first available - self.modal_state = ModelState::GpuSelection; // Stay here + self.gpus_state.select(Some(0)); + self.app_state = AppState::GpuSelection; } } else { - self.gpus_state.select(Some(0)); // Select first if no pre-selection + self.gpus_state.select(Some(0)); } - - self.loading_message = None; } Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), @@ -402,7 +390,6 @@ impl App { Ok(Ok(status)) => { self.final_status = Some(status); self.should_quit = true; // Quit after showing final status - self.loading_message = None; } Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), @@ -418,26 +405,14 @@ pub fn ui(app: &App, frame: &mut Frame) { .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - // Determine the area available for the list *before* the match statement let list_area = main_layout[0]; - // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) let available_width = list_area.width.saturating_sub(4) as usize; - if let Some(ref msg) = app.loading_message { - let loading_paragraph = Paragraph::new(msg.clone()) - .block(Block::default().title("Loading").borders(Borders::ALL)) - .alignment(Alignment::Center); - - let area = centered_rect(60, 20, frame.size()); - frame.render_widget(loading_paragraph, area); - return; // Don't render anything else while loading - } - let list_block = Block::default().borders(Borders::ALL); let list_style = Style::default().fg(Color::White); - match app.modal_state { - ModelState::LeaderboardSelection => { + match app.app_state { + AppState::LeaderboardSelection => { let items: Vec = app .leaderboards .iter() @@ -446,7 +421,6 @@ pub fn ui(app: &App, frame: &mut Frame) { lb.title_text.clone(), Style::default().fg(Color::White).bold(), )); - // Create lines for the description, splitting by newline let mut lines = vec![title_line]; for desc_part in lb.task_description.split('\n') { lines.push(Line::from(Span::styled( @@ -454,7 +428,7 @@ pub fn ui(app: &App, frame: &mut Frame) { Style::default().fg(Color::Gray).dim(), ))); } - ListItem::new(lines) // Use the combined vector of lines + ListItem::new(lines) }) .collect(); let list = List::new(items) @@ -464,17 +438,16 @@ pub fn ui(app: &App, frame: &mut Frame) { .highlight_symbol("> "); frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); } - ModelState::GpuSelection => { + AppState::GpuSelection => { let items: Vec = app .gpus .iter() .map(|gpu| { - // GPUs still only have a title line let line = Line::from(vec![Span::styled( gpu.title_text.clone(), Style::default().fg(Color::White).bold(), )]); - ListItem::new(line) // Keep as single line + ListItem::new(line) }) .collect(); let list = List::new(items) @@ -487,74 +460,37 @@ pub fn ui(app: &App, frame: &mut Frame) { .highlight_symbol("> "); frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { let items: Vec = app .submission_modes .iter() .map(|mode| { - let title_line = Line::from(Span::styled( + let strings = utils::custom_wrap( mode.title_text.clone(), - Style::default().fg(Color::White).bold(), - )); - - let mut lines = vec![title_line]; - let description_text = &mode.description_text; - - // Manual wrapping logic - if available_width > 0 { - let mut current_line = String::with_capacity(available_width); - for word in description_text.split_whitespace() { - // Check if the word itself is too long - if word.len() > available_width { - // If a line is currently being built, push it first - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - current_line.clear(); - } - // Push the long word on its own line - lines.push(Line::from(Span::styled( - word.to_string(), - Style::default().fg(Color::Gray).dim(), - ))); - } else if current_line.is_empty() { - // Start a new line - current_line.push_str(word); - } else if current_line.len() + word.len() + 1 <= available_width { - // Add word to current line - current_line.push(' '); - current_line.push_str(word); + mode.description_text.clone(), + available_width, + ); + + let lines: Vec = strings + .into_iter() + .enumerate() + .map(|(i, line)| { + if i == 0 { + Line::from(Span::styled( + line, + Style::default().fg(Color::White).bold(), + )) } else { - // Word doesn't fit, push the completed line - lines.push(Line::from(Span::styled( - current_line.clone(), + Line::from(Span::styled( + line.clone(), Style::default().fg(Color::Gray).dim(), - ))); - // Start a new line with the current word - current_line.clear(); - current_line.push_str(word); + )) } - } - // Push the last remaining line if it's not empty - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line, - Style::default().fg(Color::Gray).dim(), - ))); - } - } else { - // Fallback: push the original description as one line if width is zero - lines.push(Line::from(Span::styled( - description_text.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - } - + }) + .collect::>(); ListItem::new(lines) }) - .collect(); + .collect::>(); let list = List::new(items) .block(list_block.title(format!( "Select Submission Mode for '{}' on '{}'", @@ -570,32 +506,17 @@ pub fn ui(app: &App, frame: &mut Frame) { &mut app.submission_modes_state.clone(), ); } - ModelState::WaitingForResult => { - // This state is handled by the loading message check at the beginning + AppState::WaitingForResult => { + let loading_page = LoadingPage::default(); + frame.render_stateful_widget( + &loading_page, + main_layout[0], + &mut app.loading_page_state.clone(), + ) } } } -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - pub async fn run_submit_tui( filepath: Option, gpu: Option, @@ -632,7 +553,7 @@ pub async fn run_submit_tui( let mut file = File::open(&file_to_submit)?; let mut file_content = String::new(); file.read_to_string(&mut file_content)?; - + // Create client and submit directly let client = service::create_client(Some(cli_id))?; println!("Submitting solution directly with:"); @@ -640,19 +561,21 @@ pub async fn run_submit_tui( println!(" Leaderboard: {}", leaderboard_flag); println!(" GPU: {}", gpu_flag); println!(" Mode: {}", mode_flag); - + // Make the submission let result = service::submit_solution( - &client, - &file_to_submit, - &file_content, - leaderboard_flag, - gpu_flag, - mode_flag - ).await?; - + &client, + &file_to_submit, + &file_content, + leaderboard_flag, + gpu_flag, + mode_flag, + ) + .await?; + + println!("Submission result: {}", result); + utils::display_ascii_art(); - println!("{}", result); return Ok(()); } @@ -669,7 +592,7 @@ pub async fn run_submit_tui( app.selected_submission_mode = Some(mode_flag); // Skip to submission if we have all required fields if app.selected_gpu.is_some() && app.selected_leaderboard.is_some() { - app.modal_state = ModelState::WaitingForResult; + app.app_state = AppState::WaitingForResult; } } @@ -680,27 +603,23 @@ pub async fn run_submit_tui( // Spawn the initial task based on the starting state BEFORE setting up the TUI // If spawning fails here, we just return the error directly without TUI cleanup. - match app.modal_state { - ModelState::LeaderboardSelection => { + match app.app_state { + AppState::LeaderboardSelection => { if let Err(e) = app.spawn_load_leaderboards() { return Err(anyhow!("Error starting leaderboard fetch: {}", e)); } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Err(e) = app.spawn_load_gpus() { return Err(anyhow!("Error starting GPU fetch: {}", e)); } } - ModelState::WaitingForResult => { - // This state occurs when all flags (gpu, leaderboard, mode) are provided + AppState::WaitingForResult => { if let Err(e) = app.spawn_submit_solution() { return Err(anyhow!("Error starting submission: {}", e)); } } - _ => { - // Other states like SubmissionModeSelection shouldn't be the *initial* state - // unless there's a logic error elsewhere. We'll proceed to TUI. - } + _ => {} } // Now, set up the TUI @@ -710,16 +629,15 @@ pub async fn run_submit_tui( let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Main application loop - this remains largely the same while !app.should_quit { terminal.draw(|f| ui(&app, f))?; - // Check for finished async tasks without blocking drawing app.check_leaderboard_task().await; app.check_gpu_task().await; app.check_submission_task().await; - // Handle input events + app.update_loading_page_state(terminal.size()?.width); + if event::poll(std::time::Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { @@ -729,6 +647,33 @@ pub async fn run_submit_tui( } } + let mut result_text = "Submission cancelled.".to_string(); + + if let Some(status) = app.final_status { + let trimmed = status.trim(); + let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + + let content = content.replace("\\n", "\n"); + + result_text = content.to_string(); + } + + let state = &mut app.result_page_state; + + let mut result_page = ResultPage::new(result_text.clone(), state); + while !state.ack { + terminal + .draw(|frame: &mut Frame| { + frame.render_stateful_widget(&result_page, frame.size(), state); + }) + .unwrap(); + result_page.handle_key_event(state); + } + // Restore terminal disable_raw_mode()?; crossterm::execute!( @@ -737,13 +682,7 @@ pub async fn run_submit_tui( )?; terminal.show_cursor()?; - utils::display_ascii_art(); - - if let Some(status) = app.final_status { - println!("{}", status); - } else { - println!("Operation cancelled."); // Or some other default message if quit early - } + // utils::display_ascii_art(); Ok(()) } diff --git a/src/main.rs b/src/main.rs index ccf767c..7f87c22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cmd; mod models; mod service; mod utils; +mod views; use crate::cmd::Cli; use clap::Parser; diff --git a/src/models/mod.rs b/src/models/mod.rs index 257f751..5a9c8a8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -43,8 +43,9 @@ impl SubmissionModeItem { } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ModelState { +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum AppState { + #[default] LeaderboardSelection, GpuSelection, SubmissionModeSelection, diff --git a/src/service/mod.rs b/src/service/mod.rs index 76b1731..8c9a971 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -175,15 +175,8 @@ pub async fn submit_solution>( "status" => (), "result" => { let result_val: Value = serde_json::from_str(data)?; - let pretty_result = match result_val.get("results") { - Some(result_obj) => serde_json::to_string_pretty(result_obj)?, - None => { - return Err(anyhow!( - "Invalid 'result' event structure: missing 'results' field" - )) - } - }; - return Ok(pretty_result); + let reports = result_val.get("reports").unwrap(); + return Ok(reports.to_string()); } "error" => { let error_val: Value = serde_json::from_str(data)?; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index cb29342..4e940ad 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -48,33 +48,68 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir )) } -pub fn display_ascii_art() { +pub fn get_ascii_art() -> String { let art = r#" - _ __ _ ______ _ -| | / / | | | ___ \ | | -| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ -| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| -| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ -\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| - - POPCORN CLI - GPU MODE - - ┌───────────────────────────────────────┐ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ - │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ - │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ - │ └─────┘ └─────┘ └─────┘ │████████│ │▒ - │ │████████│ │▒ - │ ┌────────────────────────┐ │████████│ │▒ - │ │ │ │████████│ │▒ - │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ - │ │ │ │▒ - │ └────────────────────────┘ │▒ - │ │▒ - └───────────────────────────────────────┘▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ -"#; + _ __ _ ______ _ + | | / / | | | ___ \ | | + | |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ + | \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| + | |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ + \_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + "#; + + art.to_string() +} + + +pub fn display_ascii_art() { + let art = get_ascii_art(); println!("{}", art); } + +pub fn custom_wrap(initial_text: String, remaining_text: String, available_width: usize) -> Vec { + let mut lines = vec![initial_text]; + let mut current_line = String::with_capacity(available_width); + for word in remaining_text.split_whitespace() { + if word.len() > available_width { + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } + lines.push(word.to_string()); + } else if current_line.is_empty() { + current_line.push_str(word); + } else if current_line.len() + word.len() + 1 <= available_width { + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line.clone()); + current_line.clear(); + current_line.push_str(word); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + lines +} diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs new file mode 100644 index 0000000..a01512f --- /dev/null +++ b/src/views/loading_page.rs @@ -0,0 +1,73 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Layout, Rect}, + style::{Color, Stylize}, + widgets::{Block, Gauge, Padding, Paragraph, StatefulWidget, Widget}, +}; + +#[derive(Debug, Default, Clone)] +pub struct LoadingPageState { + pub loop_count: u16, + pub progress_column: u16, + pub progress_bar: f64, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub struct LoadingPage { + header_area: Rect, + gauge_area: Rect, + footer_area: Rect, +} + +const GAUGE_COLOR: Color = ratatui::style::palette::tailwind::RED.c800; + +impl StatefulWidget for &LoadingPage { + type State = LoadingPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + use ratatui::layout::Constraint::Percentage; + + let layout = Layout::vertical([Percentage(45), Percentage(10), Percentage(45)]); + + let [_, gauge_area, footer_area] = layout.areas(area); + + render_gauge(gauge_area, buf, state); + render_footer(footer_area, buf, state); + } +} + +fn render_gauge(area: Rect, buf: &mut Buffer, state: &mut LoadingPageState) { + let blk = Block::default().padding(Padding::horizontal(20)); + Gauge::default() + .block(blk) + .gauge_style(GAUGE_COLOR) + .ratio(state.progress_bar / 100.0) + .render(area, buf); +} + +fn get_footer_text(state: &LoadingPageState) -> String { + let percentage = state.progress_bar; + + if state.loop_count > 0 { + return "Did you know we have zero idea how long this will take?".to_string(); + } + + if percentage > 75.0 { + return "Almost there!".to_string(); + } else if percentage > 35.0 { + return "Crunching numbers...".to_string(); + } else { + return "This is taking a while, huh?".to_string(); + } +} + +fn render_footer(area: Rect, buf: &mut Buffer, state: &LoadingPageState) { + let blk = Block::default().padding(Padding::vertical(1)); + let text = Paragraph::new(get_footer_text(state)) + .alignment(Alignment::Center) + .fg(Color::White) + .bold() + .block(blk); + + text.render(area, buf); +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..a0e6eff --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,2 @@ +pub mod result_page; +pub mod loading_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs new file mode 100644 index 0000000..a749416 --- /dev/null +++ b/src/views/result_page.rs @@ -0,0 +1,149 @@ +use crate::utils; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Margin, Rect}, + prelude::Buffer, + style::{Color, Style}, + symbols::scrollbar, + widgets::{Block, BorderType, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget}, +}; + +#[derive(Default, Debug)] +pub struct ResultPageState { + pub vertical_scroll: u16, + pub vertical_scroll_state: ScrollbarState, + pub horizontal_scroll: u16, + pub horizontal_scroll_state: ScrollbarState, + pub ack: bool, +} + +#[derive(Default, Debug)] +pub struct ResultPage { + result_text: Paragraph<'static>, +} + +impl ResultPage { + pub fn new(result_text: String, state: &mut ResultPageState) -> Self { + let max_width = result_text + .lines() + .map(|line| line.len()) + .max() + .unwrap_or(0); + + let num_lines = result_text.lines().count(); + + state.vertical_scroll_state = state + .vertical_scroll_state + .content_length(num_lines); + + state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); + + Self { + result_text: Paragraph::new(result_text), + } + } + + fn render_left(&self, buf: &mut Buffer, left: Rect) { + let left_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Yellow)); + + let left_text = Paragraph::new(utils::get_ascii_art()); + + left_text.block(left_block).render(left, buf); + } + + fn render_right(&self, buf: &mut Buffer, right: Rect, state: &mut ResultPageState) { + let right_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Yellow)) + .title_bottom("Press q to quit...") + .title_style(Style::default().fg(Color::Red)) + .title_alignment(Alignment::Right); + + let result_text = self + .result_text + .clone() + .block(right_block) + .scroll((state.vertical_scroll as u16, state.horizontal_scroll as u16)); + result_text.render(right, buf); + } + + pub fn handle_key_event(&mut self, state: &mut ResultPageState) { + if event::poll(std::time::Duration::from_millis(50)).unwrap() { + if let Event::Key(key) = event::read().unwrap() { + if key.kind != KeyEventKind::Press { + return; + } + if key.code == KeyCode::Char('q') { + state.ack = true; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + state.vertical_scroll = state.vertical_scroll.saturating_add(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('k') | KeyCode::Up => { + state.vertical_scroll = state.vertical_scroll.saturating_sub(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('h') | KeyCode::Left => { + state.horizontal_scroll = state.horizontal_scroll.saturating_sub(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + KeyCode::Char('l') | KeyCode::Right => { + state.horizontal_scroll = state.horizontal_scroll.saturating_add(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + _ => {} + } + } + } + } +} + +impl StatefulWidget for &ResultPage { + type State = ResultPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut ResultPageState) { + let layout = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]); + let [left, right] = layout.areas(area); + + self.render_left(buf, left); + self.render_right(buf, right, state); + + let vertical_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalLeft) + .symbols(scrollbar::VERTICAL); + + let horizontal_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::HorizontalBottom) + .symbols(scrollbar::HORIZONTAL); + + vertical_scrollbar.render( + right.inner(&Margin { + vertical: 1, + horizontal: 0, + }), + buf, + &mut state.vertical_scroll_state, + ); + horizontal_scrollbar.render( + right.inner(&Margin { + vertical: 0, + horizontal: 1, + }), + buf, + &mut state.horizontal_scroll_state, + ); + } +}