diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 617bfad..42d6b48 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -158,7 +158,16 @@ pub async fn execute(cli: Cli) -> Result<()> { ) .await } else { - Err(anyhow!("No command or submission file specified. Use --help for usage.")) + // No file provided, start with welcome screen + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + submit::run_submit_tui(None, None, None, None, cli_id).await } } } diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 83411be..e39c43e 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -6,18 +6,21 @@ use anyhow::{anyhow, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; use ratatui::prelude::*; -use ratatui::style::{Color, Style, Stylize}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; +use ratatui::widgets::ListState; use tokio::task::JoinHandle; -use crate::models::{AppState, GpuItem, LeaderboardItem, SubmissionModeItem}; +use crate::models::{ + AppState, GpuItem, GpuSelectionView, LeaderboardItem, LeaderboardSelectionView, + SelectionAction, SelectionItem, SelectionView, SubmissionModeItem, SubmissionModeSelectionView, +}; use crate::service; use crate::utils; +use crate::views::file_selection_page::{FileSelectionAction, FileSelectionView}; use crate::views::loading_page::{LoadingPage, LoadingPageState}; -use crate::views::result_page::{ResultPage, ResultPageState}; +use crate::views::result_page::{ResultAction, ResultPageState, ResultView}; +use crate::views::welcome_page::{WelcomeAction, WelcomeView}; -#[derive(Default, Debug)] +#[derive(Default)] pub struct App { pub filepath: String, pub cli_id: String, @@ -35,20 +38,25 @@ pub struct App { pub selected_submission_mode: Option, pub app_state: AppState, - pub final_status: 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, + + // View instances + pub welcome_view: Option, + pub file_selection_view: Option, + pub leaderboard_view: Option, + pub gpu_view: Option, + pub submission_mode_view: Option, + pub result_view: Option, } impl App { - pub fn new>(filepath: P, cli_id: String) -> Self { + pub fn new(filepath: Option, cli_id: String) -> Self { let submission_modes = vec![ SubmissionModeItem::new( "Test".to_string(), @@ -73,13 +81,26 @@ impl App { ]; let mut app = Self { - filepath: filepath.as_ref().to_string_lossy().to_string(), + filepath: filepath.unwrap_or_default(), cli_id, submission_modes, selected_submission_mode: None, + welcome_view: Some(WelcomeView::new()), + file_selection_view: None, + leaderboard_view: None, + gpu_view: None, + submission_mode_view: None, + result_view: None, ..Default::default() }; + // Set initial state based on whether filepath is provided + if app.filepath.is_empty() { + app.app_state = AppState::Welcome; + } else { + app.app_state = AppState::LeaderboardSelection; + } + app.leaderboards_state.select(Some(0)); app.gpus_state.select(Some(0)); app.submission_modes_state.select(Some(0)); @@ -104,159 +125,218 @@ impl App { } 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); + let has_leaderboard = !popcorn_directives.leaderboard_name.is_empty(); + let has_gpu = !popcorn_directives.gpus.is_empty(); + + // Set the selected values from directives + if has_leaderboard { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name.clone()); + } + if has_gpu { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + } - if !popcorn_directives.gpus.is_empty() { - self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + // Determine initial state based on what's available + match (has_leaderboard, has_gpu) { + (true, true) => { + // Both leaderboard and GPU specified - go directly to submission mode selection self.app_state = AppState::SubmissionModeSelection; - } else { + self.submission_mode_view = Some(SubmissionModeSelectionView::new( + self.submission_modes.clone(), + popcorn_directives.leaderboard_name, + popcorn_directives.gpus[0].clone(), + )); + } + (true, false) => { + // Only leaderboard specified - need to select GPU 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.app_state = AppState::SubmissionModeSelection; - } else { + (false, true) => { + // Only GPU specified - need to select leaderboard + self.app_state = AppState::LeaderboardSelection; + } + (false, false) => { + // Neither specified - start from leaderboard selection self.app_state = AppState::LeaderboardSelection; } - } else { - self.app_state = AppState::LeaderboardSelection; } } pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { - // Allow quitting anytime, even while loading + // Global key handling (esc, ctrl+c) if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { self.should_quit = true; return Ok(true); } - match key.code { - KeyCode::Char('q') => { - self.should_quit = true; - return Ok(true); + if key.code == KeyCode::Esc { + self.should_quit = true; + return Ok(true); + } + + // Delegate to views based on current state + match self.app_state { + AppState::Welcome => { + if let Some(view) = &mut self.welcome_view { + match view.handle_key_event(key) { + WelcomeAction::Submit => { + self.app_state = AppState::FileSelection; + self.file_selection_view = Some(FileSelectionView::new()?); + return Ok(true); + } + WelcomeAction::ViewHistory => { + self.show_error( + "View History feature is not yet implemented".to_string(), + ); + return Ok(true); + } + WelcomeAction::Handled => return Ok(true), + WelcomeAction::NotHandled => return Ok(false), + } + } + } + AppState::FileSelection => { + if let Some(view) = &mut self.file_selection_view { + match view.handle_key_event(key)? { + FileSelectionAction::FileSelected(filepath) => { + self.filepath = filepath.clone(); + + // Parse directives from the selected file + match utils::get_popcorn_directives(&filepath) { + Ok((directives, has_multiple_gpus)) => { + if has_multiple_gpus { + self.show_error("Multiple GPUs are not supported yet. Please specify only one GPU.".to_string()); + return Ok(true); + } + + // Apply directives to determine next state + self.initialize_with_directives(directives); + + // Spawn appropriate task based on the new state + // TODO: make spawn_x tasks also show error if they fail, a lot of duplicate code + match self.app_state { + AppState::LeaderboardSelection => { + if let Err(e) = self.spawn_load_leaderboards() { + self.show_error(format!( + "Error starting leaderboard fetch: {}", + e + )); + } + } + AppState::GpuSelection => { + if let Err(e) = self.spawn_load_gpus() { + self.show_error(format!( + "Error starting GPU fetch: {}", + e + )); + } + } + AppState::SubmissionModeSelection => { + // View already created in initialize_with_directives + } + _ => {} + } + } + Err(e) => { + self.show_error(format!( + "Error parsing file directives: {}", + e + )); + return Ok(true); + } + } + return Ok(true); + } + FileSelectionAction::Handled => return Ok(true), + FileSelectionAction::NotHandled => return Ok(false), + } + } } - 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()); + AppState::LeaderboardSelection => { + if let Some(view) = &mut self.leaderboard_view { + match view.handle_key_event(key) { + SelectionAction::Selected(idx) => { + self.selected_leaderboard = Some(view.items()[idx].title().to_string()); if self.selected_gpu.is_none() { self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { - self.set_error_and_quit(format!( - "Error starting GPU fetch: {}", - e - )); + self.show_error(format!("Error starting GPU fetch: {}", e)); } } else { self.app_state = AppState::SubmissionModeSelection; + self.submission_mode_view = Some(SubmissionModeSelectionView::new( + self.submission_modes.clone(), + self.selected_leaderboard.as_ref().unwrap().clone(), + self.selected_gpu.as_ref().unwrap().clone(), + )); } return Ok(true); } + SelectionAction::Handled => return Ok(true), + SelectionAction::NotHandled => return Ok(false), } } - 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()); + } + AppState::GpuSelection => { + if let Some(view) = &mut self.gpu_view { + match view.handle_key_event(key) { + SelectionAction::Selected(idx) => { + self.selected_gpu = Some(view.items()[idx].title().to_string()); self.app_state = AppState::SubmissionModeSelection; + self.submission_mode_view = Some(SubmissionModeSelectionView::new( + self.submission_modes.clone(), + self.selected_leaderboard.as_ref().unwrap().clone(), + self.selected_gpu.as_ref().unwrap().clone(), + )); return Ok(true); } + SelectionAction::Handled => return Ok(true), + SelectionAction::NotHandled => return Ok(false), } } - 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()); + } + AppState::SubmissionModeSelection => { + if let Some(view) = &mut self.submission_mode_view { + match view.handle_key_event(key) { + SelectionAction::Selected(idx) => { + self.selected_submission_mode = Some(view.items()[idx].value.clone()); self.app_state = AppState::WaitingForResult; if let Err(e) = self.spawn_submit_solution() { - self.set_error_and_quit(format!( - "Error starting submission: {}", - e - )); + self.show_error(format!("Error starting submission: {}", e)); } return Ok(true); } - } - } - _ => {} - }, - KeyCode::Up => { - self.move_selection_up(); - return Ok(true); - } - KeyCode::Down => { - self.move_selection_down(); - return Ok(true); - } - _ => {} - } - Ok(false) - } - - fn set_error_and_quit(&mut self, error_message: String) { - self.final_status = Some(error_message); - self.should_quit = true; - } - - fn move_selection_up(&mut self) { - match self.app_state { - AppState::LeaderboardSelection => { - if let Some(idx) = self.leaderboards_state.selected() { - if idx > 0 { - self.leaderboards_state.select(Some(idx - 1)); - } - } - } - AppState::GpuSelection => { - if let Some(idx) = self.gpus_state.selected() { - if idx > 0 { - self.gpus_state.select(Some(idx - 1)); + SelectionAction::Handled => return Ok(true), + SelectionAction::NotHandled => return Ok(false), } } } - AppState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx > 0 { - self.submission_modes_state.select(Some(idx - 1)); + AppState::ShowingResult => { + if let Some(view) = &mut self.result_view { + match view.handle_key_event(key) { + ResultAction::Quit => { + self.should_quit = true; + return Ok(true); + } + ResultAction::Handled => { + // Update scroll state based on key + view.update_scroll(key, &mut self.result_page_state); + return Ok(true); + } + ResultAction::NotHandled => return Ok(false), } } } _ => {} } + + Ok(false) } - fn move_selection_down(&mut self) { - 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)); - } - } - } - 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)); - } - } - } - 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)); - } - } - } - _ => {} - } + fn show_error(&mut self, error_message: String) { + self.result_view = Some(ResultView::new(error_message)); + self.app_state = AppState::ShowingResult; } pub fn spawn_load_leaderboards(&mut self) -> Result<()> { @@ -313,39 +393,46 @@ impl App { let task = self.leaderboards_task.take().unwrap(); match task.await { Ok(Ok(leaderboards)) => { - self.leaderboards = leaderboards; + self.leaderboards = leaderboards.clone(); + self.leaderboard_view = Some(LeaderboardSelectionView::new(leaderboards)); + if let Some(selected_name) = &self.selected_leaderboard { if let Some(index) = self .leaderboards .iter() - .position(|lb| &lb.title_text == selected_name) + .position(|lb| lb.title() == selected_name) { - self.leaderboards_state.select(Some(index)); + if let Some(view) = &mut self.leaderboard_view { + view.state_mut().select(Some(index)); + } if self.selected_gpu.is_some() { self.app_state = AppState::SubmissionModeSelection; + self.submission_mode_view = + Some(SubmissionModeSelectionView::new( + self.submission_modes.clone(), + self.selected_leaderboard.as_ref().unwrap().clone(), + self.selected_gpu.as_ref().unwrap().clone(), + )); } else { self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { - self.set_error_and_quit(format!( - "Error starting GPU fetch: {}", - e - )); + self.show_error(format!("Error starting GPU fetch: {}", e)); return; } } } else { self.selected_leaderboard = None; - self.leaderboards_state.select(Some(0)); + if let Some(view) = &mut self.leaderboard_view { + view.state_mut().select(Some(0)); + } self.app_state = AppState::LeaderboardSelection; } - } else { - self.leaderboards_state.select(Some(0)); + } else if let Some(view) = &mut self.leaderboard_view { + view.state_mut().select(Some(0)); } } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) - } - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + Ok(Err(e)) => self.show_error(format!("Error fetching leaderboards: {}", e)), + Err(e) => self.show_error(format!("Task join error: {}", e)), } } } @@ -357,26 +444,43 @@ impl App { let task = self.gpus_task.take().unwrap(); match task.await { Ok(Ok(gpus)) => { - self.gpus = gpus; + self.gpus = gpus.clone(); + self.gpu_view = Some(GpuSelectionView::new( + gpus, + self.selected_leaderboard + .as_ref() + .unwrap_or(&"N/A".to_string()) + .clone(), + )); + if let Some(selected_name) = &self.selected_gpu { if let Some(index) = self .gpus .iter() - .position(|gpu| &gpu.title_text == selected_name) + .position(|gpu| gpu.title() == selected_name) { - self.gpus_state.select(Some(index)); + if let Some(view) = &mut self.gpu_view { + view.state_mut().select(Some(index)); + } self.app_state = AppState::SubmissionModeSelection; + self.submission_mode_view = Some(SubmissionModeSelectionView::new( + self.submission_modes.clone(), + self.selected_leaderboard.as_ref().unwrap().clone(), + self.selected_gpu.as_ref().unwrap().clone(), + )); } else { self.selected_gpu = None; - self.gpus_state.select(Some(0)); + if let Some(view) = &mut self.gpu_view { + view.state_mut().select(Some(0)); + } self.app_state = AppState::GpuSelection; } - } else { - self.gpus_state.select(Some(0)); + } else if let Some(view) = &mut self.gpu_view { + view.state_mut().select(Some(0)); } } - 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)), + Ok(Err(e)) => self.show_error(format!("Error fetching GPUs: {}", e)), + Err(e) => self.show_error(format!("Task join error: {}", e)), } } } @@ -388,132 +492,79 @@ impl App { let task = self.submission_task.take().unwrap(); match task.await { Ok(Ok(status)) => { - self.final_status = Some(status); - self.should_quit = true; // Quit after showing final status + // Process the status text + 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"); + + // Create result view and transition to showing result + self.result_view = Some(ResultView::new(content)); + self.app_state = AppState::ShowingResult; + } + Ok(Err(e)) => { + // Show error in result view + self.result_view = + Some(ResultView::new(format!("Submission error: {}", e))); + self.app_state = AppState::ShowingResult; + } + Err(e) => { + // Show task join error in result view + self.result_view = Some(ResultView::new(format!("Task join error: {}", e))); + self.app_state = AppState::ShowingResult; } - Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), } } } } } -pub fn ui(app: &App, frame: &mut Frame) { - let main_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0)].as_ref()) - .split(frame.size()); - - let list_area = main_layout[0]; - let available_width = list_area.width.saturating_sub(4) as usize; - - let list_block = Block::default().borders(Borders::ALL); - let list_style = Style::default().fg(Color::White); - +pub fn ui(app: &mut App, frame: &mut Frame) { match app.app_state { + AppState::Welcome => { + if let Some(view) = &mut app.welcome_view { + view.render(frame); + } + } + AppState::FileSelection => { + if let Some(view) = &mut app.file_selection_view { + view.render(frame); + } + } AppState::LeaderboardSelection => { - let items: Vec = app - .leaderboards - .iter() - .map(|lb| { - let title_line = Line::from(Span::styled( - lb.title_text.clone(), - Style::default().fg(Color::White).bold(), - )); - let mut lines = vec![title_line]; - for desc_part in lb.task_description.split('\n') { - lines.push(Line::from(Span::styled( - desc_part.to_string(), - Style::default().fg(Color::Gray).dim(), - ))); - } - ListItem::new(lines) - }) - .collect(); - let list = List::new(items) - .block(list_block.title("Select Leaderboard")) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); + if let Some(view) = &mut app.leaderboard_view { + view.render(frame); + } } AppState::GpuSelection => { - let items: Vec = app - .gpus - .iter() - .map(|gpu| { - let line = Line::from(vec![Span::styled( - gpu.title_text.clone(), - Style::default().fg(Color::White).bold(), - )]); - ListItem::new(line) - }) - .collect(); - let list = List::new(items) - .block(list_block.title(format!( - "Select GPU for '{}'", - app.selected_leaderboard.as_deref().unwrap_or("N/A") - ))) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); + if let Some(view) = &mut app.gpu_view { + view.render(frame); + } } AppState::SubmissionModeSelection => { - let items: Vec = app - .submission_modes - .iter() - .map(|mode| { - let strings = utils::custom_wrap( - mode.title_text.clone(), - 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 { - Line::from(Span::styled( - line.clone(), - Style::default().fg(Color::Gray).dim(), - )) - } - }) - .collect::>(); - ListItem::new(lines) - }) - .collect::>(); - let list = List::new(items) - .block(list_block.title(format!( - "Select Submission Mode for '{}' on '{}'", - app.selected_leaderboard.as_deref().unwrap_or("N/A"), - app.selected_gpu.as_deref().unwrap_or("N/A") - ))) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget( - list, - main_layout[0], - &mut app.submission_modes_state.clone(), - ); + if let Some(view) = &mut app.submission_mode_view { + view.render(frame); + } } AppState::WaitingForResult => { let loading_page = LoadingPage::default(); frame.render_stateful_widget( &loading_page, - main_layout[0], + frame.size(), &mut app.loading_page_state.clone(), ) } + AppState::ShowingResult => { + if let Some(view) = &mut app.result_view { + view.render(frame, &mut app.result_page_state); + } + } } } @@ -525,69 +576,64 @@ pub async fn run_submit_tui( cli_id: String, ) -> Result<()> { let file_to_submit = match filepath { - Some(fp) => fp, - None => { - // Prompt user for filepath if not provided - println!("Please enter the path to your solution file:"); - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - input.trim().to_string() + Some(fp) => { + if !Path::new(&fp).exists() { + return Err(anyhow!("File not found: {}", fp)); + } + Some(fp) } + None => None, }; - if !Path::new(&file_to_submit).exists() { - return Err(anyhow!("File not found: {}", file_to_submit)); - } - - let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; + let mut app = App::new(file_to_submit.clone(), cli_id); - if has_multiple_gpus { - return Err(anyhow!( - "Multiple GPUs are not supported yet. Please specify only one GPU." - )); - } + // If we have a filepath, process directives and setup initial state + if let Some(ref file_path) = file_to_submit { + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(file_path)?; - let mut app = App::new(&file_to_submit, cli_id); - - // Override directives with CLI flags if provided - if let Some(gpu_flag) = gpu { - app.selected_gpu = Some(gpu_flag); - } - if let Some(leaderboard_flag) = leaderboard { - app.selected_leaderboard = Some(leaderboard_flag); - } - if let Some(mode_flag) = mode { - 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.app_state = AppState::WaitingForResult; + if has_multiple_gpus { + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); } - } - // If no CLI flags, use directives - if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { + // First apply directives as defaults app.initialize_with_directives(directives); - } - // 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.app_state { - AppState::LeaderboardSelection => { - if let Err(e) = app.spawn_load_leaderboards() { - return Err(anyhow!("Error starting leaderboard fetch: {}", e)); - } + // Then override with CLI flags if provided + if let Some(gpu_flag) = gpu { + app.selected_gpu = Some(gpu_flag); } - AppState::GpuSelection => { - if let Err(e) = app.spawn_load_gpus() { - return Err(anyhow!("Error starting GPU fetch: {}", e)); + if let Some(leaderboard_flag) = leaderboard { + app.selected_leaderboard = Some(leaderboard_flag); + } + if let Some(mode_flag) = mode { + 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.app_state = AppState::WaitingForResult; } } - AppState::WaitingForResult => { - if let Err(e) = app.spawn_submit_solution() { - return Err(anyhow!("Error starting submission: {}", e)); + + // Spawn the initial task based on the starting state BEFORE setting up the TUI + match app.app_state { + AppState::LeaderboardSelection => { + if let Err(e) = app.spawn_load_leaderboards() { + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); + } + } + AppState::GpuSelection => { + if let Err(e) = app.spawn_load_gpus() { + return Err(anyhow!("Error starting GPU fetch: {}", e)); + } } + AppState::WaitingForResult => { + if let Err(e) = app.spawn_submit_solution() { + return Err(anyhow!("Error starting submission: {}", e)); + } + } + _ => {} } - _ => {} } // Now, set up the TUI @@ -598,7 +644,7 @@ pub async fn run_submit_tui( let mut terminal = Terminal::new(backend)?; while !app.should_quit { - terminal.draw(|f| ui(&app, f))?; + terminal.draw(|f| ui(&mut app, f))?; app.check_leaderboard_task().await; app.check_gpu_task().await; @@ -615,39 +661,6 @@ 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); - let mut last_draw = std::time::Instant::now(); - while !state.ack { - // Force redraw every 100ms for smooth animation - let now = std::time::Instant::now(); - if now.duration_since(last_draw) >= std::time::Duration::from_millis(100) { - terminal - .draw(|frame: &mut Frame| { - frame.render_stateful_widget(&result_page, frame.size(), state); - }) - .unwrap(); - last_draw = now; - } - result_page.handle_key_event(state); - } - // Restore terminal disable_raw_mode()?; crossterm::execute!( @@ -656,7 +669,5 @@ pub async fn run_submit_tui( )?; terminal.show_cursor()?; - // utils::display_ascii_art(); - Ok(()) } diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a9c8a8..a0cdb28 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,13 @@ use serde::{Deserialize, Serialize}; +use ratatui::widgets::{ListItem, Block, Borders, List, ListState}; +use ratatui::text::{Line, Span}; +use ratatui::style::{Style, Color}; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + Frame, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use crate::utils; #[derive(Clone, Debug)] pub struct LeaderboardItem { @@ -46,11 +55,287 @@ impl SubmissionModeItem { #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum AppState { #[default] + Welcome, + FileSelection, LeaderboardSelection, GpuSelection, SubmissionModeSelection, WaitingForResult, + ShowingResult, } #[derive(Debug, Serialize, Deserialize)] pub struct SubmissionResultMsg(pub String); + +pub trait SelectionItem { + fn title(&self) -> &str; + + #[allow(dead_code)] + fn description(&self) -> Option<&str> { + None + } + fn to_list_item(&self, available_width: usize) -> ListItem; +} + +pub trait SelectionView { + fn title(&self) -> String; + fn items(&self) -> &[T]; + fn state(&self) -> &ListState; + fn state_mut(&mut self) -> &mut ListState; + + fn handle_key_event(&mut self, key: KeyEvent) -> SelectionAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if let Some(selected) = self.state().selected() { + if selected > 0 { + self.state_mut().select(Some(selected - 1)); + } + } + SelectionAction::Handled + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.state().selected() { + if selected < self.items().len().saturating_sub(1) { + self.state_mut().select(Some(selected + 1)); + } + } + SelectionAction::Handled + } + KeyCode::Enter => { + if let Some(selected) = self.state().selected() { + if selected < self.items().len() { + SelectionAction::Selected(selected) + } else { + SelectionAction::Handled + } + } else { + SelectionAction::Handled + } + } + _ => SelectionAction::NotHandled, + } + } + + fn render(&mut self, frame: &mut Frame) { + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)].as_ref()) + .split(frame.size()); + + let list_area = main_layout[0]; + let available_width = list_area.width.saturating_sub(4) as usize; + + // Get all the data we need first to avoid borrowing conflicts + let title = self.title().to_string(); + let layout_area = main_layout[0]; + + let items = self.items().to_vec(); + + let list_items: Vec = items + .iter() + .map(|item| item.to_list_item(available_width)) + .collect(); + + // Create the list widget with orange theme colors + let list = List::new(list_items) + .block(Block::default() + .borders(Borders::ALL) + .title(title.clone()) + .border_style(Style::default().fg(Color::Rgb(218, 119, 86)))) + .style(Style::default().fg(Color::White)) + .highlight_style(Style::default() + .bg(Color::Rgb(218, 119, 86)) + .fg(Color::White)) + .highlight_symbol("► "); + + frame.render_stateful_widget(list, layout_area, self.state_mut()); + } +} + +#[derive(Debug, PartialEq)] +pub enum SelectionAction { + Handled, + NotHandled, + Selected(usize), +} + +impl SelectionItem for LeaderboardItem { + fn title(&self) -> &str { + &self.title_text + } + + fn description(&self) -> Option<&str> { + Some(&self.task_description) + } + + fn to_list_item(&self, available_width: usize) -> ListItem { + let title_line = Line::from(vec![ + Span::styled(self.title_text.clone(), Style::default().fg(Color::White)), + ]); + + // Wrap long descriptions to fit available width + let max_desc_width = available_width.saturating_sub(2); // Leave some padding + let wrapped_lines = utils::wrap_text(&self.task_description, max_desc_width); + + let mut lines = vec![title_line]; + for wrapped_line in wrapped_lines { + lines.push(Line::from(vec![ + Span::styled(wrapped_line, Style::default().fg(Color::Gray)), + ])); + } + + ListItem::new(lines) + } +} + +impl SelectionItem for GpuItem { + fn title(&self) -> &str { + &self.title_text + } + + fn to_list_item(&self, _available_width: usize) -> ListItem { + let title_line = Line::from(vec![ + Span::styled(self.title_text.clone(), Style::default().fg(Color::White)), + ]); + + ListItem::new(vec![title_line]) + } +} + +impl SelectionItem for SubmissionModeItem { + fn title(&self) -> &str { + &self.title_text + } + + fn description(&self) -> Option<&str> { + Some(&self.description_text) + } + + fn to_list_item(&self, available_width: usize) -> ListItem { + let title_line = Line::from(vec![ + Span::styled(self.title_text.clone(), Style::default().fg(Color::White)), + ]); + + // Wrap long descriptions to fit available width + let max_desc_width = available_width.saturating_sub(2); // Leave some padding + let wrapped_lines = utils::wrap_text(&self.description_text, max_desc_width); + + let mut lines = vec![title_line]; + for wrapped_line in wrapped_lines { + lines.push(Line::from(vec![ + Span::styled(wrapped_line, Style::default().fg(Color::Gray)), + ])); + } + + ListItem::new(lines) + } +} + +// Selection view implementations for different types +pub struct LeaderboardSelectionView { + leaderboards: Vec, + leaderboards_state: ListState, +} + +impl LeaderboardSelectionView { + pub fn new(leaderboards: Vec) -> Self { + let mut state = ListState::default(); + state.select(Some(0)); + Self { + leaderboards, + leaderboards_state: state, + } + } +} + +impl SelectionView for LeaderboardSelectionView { + fn title(&self) -> String { + "Select Leaderboard".to_string() + } + + fn items(&self) -> &[LeaderboardItem] { + &self.leaderboards + } + + fn state(&self) -> &ListState { + &self.leaderboards_state + } + + fn state_mut(&mut self) -> &mut ListState { + &mut self.leaderboards_state + } +} + +pub struct GpuSelectionView { + gpus: Vec, + gpus_state: ListState, + leaderboard_name: String, +} + +impl GpuSelectionView { + pub fn new(gpus: Vec, leaderboard_name: String) -> Self { + let mut state = ListState::default(); + state.select(Some(0)); + Self { + gpus, + gpus_state: state, + leaderboard_name, + } + } +} + +impl SelectionView for GpuSelectionView { + fn title(&self) -> String { + format!("Select GPU for '{}'", self.leaderboard_name) + } + + fn items(&self) -> &[GpuItem] { + &self.gpus + } + + fn state(&self) -> &ListState { + &self.gpus_state + } + + fn state_mut(&mut self) -> &mut ListState { + &mut self.gpus_state + } +} + +pub struct SubmissionModeSelectionView { + submission_modes: Vec, + submission_modes_state: ListState, + leaderboard_name: String, + gpu_name: String, +} + +impl SubmissionModeSelectionView { + pub fn new(submission_modes: Vec, leaderboard_name: String, gpu_name: String) -> Self { + let mut state = ListState::default(); + state.select(Some(0)); + Self { + submission_modes, + submission_modes_state: state, + leaderboard_name, + gpu_name, + } + } +} + +impl SelectionView for SubmissionModeSelectionView { + fn title(&self) -> String { + format!("Select Submission Mode for '{}' on '{}'", self.leaderboard_name, self.gpu_name) + } + + fn items(&self) -> &[SubmissionModeItem] { + &self.submission_modes + } + + fn state(&self) -> &ListState { + &self.submission_modes_state + } + + fn state_mut(&mut self) -> &mut ListState { + &mut self.submission_modes_state + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d44304a..a00e09b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,6 @@ +use anyhow::Result; use std::fs; use std::path::Path; -use anyhow::Result; pub struct PopcornDirectives { pub leaderboard_name: String, @@ -9,7 +9,7 @@ pub struct PopcornDirectives { pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { let content = fs::read_to_string(filepath)?; - + let mut gpus: Vec = Vec::new(); let mut leaderboard_name = String::new(); let mut has_multiple_gpus = false; @@ -44,7 +44,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir leaderboard_name, gpus, }, - has_multiple_gpus + has_multiple_gpus, )) } @@ -74,7 +74,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), 1 => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -98,7 +99,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), _ => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -122,37 +124,30 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string() + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), } } -pub fn get_ascii_art() -> String { - get_ascii_art_frame(0) -} +pub fn wrap_text(text: &str, available_width: usize) -> Vec { + if text.len() <= available_width { + return vec![text.to_string()]; + } -pub fn display_ascii_art() { - let art = get_ascii_art(); - println!("{}", art); -} + let mut lines = Vec::new(); + let mut current_line = String::new(); -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 { + for word in text.split_whitespace() { + if current_line.len() + word.len() + 1 <= available_width { if !current_line.is_empty() { - lines.push(current_line.clone()); - current_line.clear(); + current_line.push(' '); } - 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(); + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } current_line.push_str(word); } } @@ -160,5 +155,6 @@ pub fn custom_wrap(initial_text: String, remaining_text: String, available_width if !current_line.is_empty() { lines.push(current_line); } + lines } diff --git a/src/views/ascii_art.rs b/src/views/ascii_art.rs new file mode 100644 index 0000000..b9a7ead --- /dev/null +++ b/src/views/ascii_art.rs @@ -0,0 +1,69 @@ +pub struct AsciiArt; + +impl AsciiArt { + pub fn kernelbot_title() -> Vec<&'static str> { + vec![ + "██╗ ██╗███████╗██████╗ ███╗ ██╗███████╗██╗ ██████╗ ██████╗ ████████╗", + "██║ ██╔╝██╔════╝██╔══██╗████╗ ██║██╔════╝██║ ██╔══██╗██╔═══██╗╚══██╔══╝", + "█████╔╝ █████╗ ██████╔╝██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ██║ ██║ ", + "██╔═██╗ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ██║ ██╔══██╗██║ ██║ ██║ ", + "██║ ██╗███████╗██║ ██║██║ ╚████║███████╗███████╗██████╔╝╚██████╔╝ ██║ ", + "╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝ ", + ] + } + + pub fn submit_menu_item(selected: bool) -> Vec<&'static str> { + if selected { + vec![ + "▶▶▶ ╔═╗╦ ╦╔╗ ╔╦╗╦╔╦╗ ◀◀◀", + "▶▶▶ ╚═╗║ ║╠╩╗║║║║ ║ ◀◀◀", + "▶▶▶ ╚═╝╚═╝╚═╝╩ ╩╩ ╩ ◀◀◀", + ] + } else { + vec![ + " ╔═╗╦ ╦╔╗ ╔╦╗╦╔╦╗ ", + " ╚═╗║ ║╠╩╗║║║║ ║ ", + " ╚═╝╚═╝╚═╝╩ ╩╩ ╩ ", + ] + } + } + + pub fn history_menu_item(selected: bool) -> Vec<&'static str> { + if selected { + vec![ + "▶▶▶ ╦ ╦╦╔═╗╔╦╗╔═╗╦═╗╦ ╦ ◀◀◀", + "▶▶▶ ╠═╣║╚═╗ ║ ║ ║╠╦╝╚╦╝ ◀◀◀", + "▶▶▶ ╩ ╩╩╚═╝ ╩ ╚═╝╩╚═ ╩ ◀◀◀", + ] + } else { + vec![ + " ╦ ╦╦╔═╗╔╦╗╔═╗╦═╗╦ ╦ ", + " ╠═╣║╚═╗ ║ ║ ║╠╦╝╚╦╝ ", + " ╩ ╩╩╚═╝ ╩ ╚═╝╩╚═ ╩ ", + ] + } + } +} + +pub fn create_background_pattern(width: u16, height: u16) -> String { + let mut pattern = String::new(); + + for y in 0..height { + for x in 0..width { + // Create a pattern with dots and circuit-like characters + let char = match (x % 8, y % 4) { + (0, 0) => '░', + (4, 2) => '░', + (2, 1) => '·', + (6, 3) => '·', + _ => ' ', + }; + pattern.push(char); + } + if y < height - 1 { + pattern.push('\n'); + } + } + + pattern +} \ No newline at end of file diff --git a/src/views/file_selection_page.rs b/src/views/file_selection_page.rs new file mode 100644 index 0000000..1c9f9a6 --- /dev/null +++ b/src/views/file_selection_page.rs @@ -0,0 +1,199 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, PartialEq)] +pub enum FileSelectionAction { + Handled, + NotHandled, + FileSelected(String), +} + +pub struct FileSelectionView { + current_dir: PathBuf, + entries: Vec, + state: ListState, +} + +impl FileSelectionView { + pub fn new() -> Result { + let current_dir = std::env::current_dir()?; + let mut view = Self { + current_dir, + entries: Vec::new(), + state: ListState::default(), + }; + view.load_directory()?; + view.state.select(Some(0)); + Ok(view) + } + + pub fn load_directory(&mut self) -> Result<()> { + self.entries.clear(); + + // Add parent directory option + if let Some(parent) = self.current_dir.parent() { + self.entries.push(parent.to_path_buf()); + } + + // Read directory entries + let mut entries: Vec = fs::read_dir(&self.current_dir)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| !name.starts_with('.')) + .unwrap_or(true) + }) + .collect(); + + // Sort directories first, then files + entries.sort_by(|a, b| { + let a_is_dir = a.is_dir(); + let b_is_dir = b.is_dir(); + if a_is_dir != b_is_dir { + b_is_dir.cmp(&a_is_dir) + } else { + a.file_name().cmp(&b.file_name()) + } + }); + + self.entries.extend(entries); + Ok(()) + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if let Some(selected) = self.state.selected() { + if selected > 0 { + self.state.select(Some(selected - 1)); + } + } + Ok(FileSelectionAction::Handled) + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.state.selected() { + if selected < self.entries.len().saturating_sub(1) { + self.state.select(Some(selected + 1)); + } + } + Ok(FileSelectionAction::Handled) + } + KeyCode::Enter => { + if let Some(selected) = self.state.selected() { + if selected < self.entries.len() { + let path = &self.entries[selected]; + if path.is_dir() { + self.current_dir = path.clone(); + self.load_directory()?; + self.state.select(Some(0)); + Ok(FileSelectionAction::Handled) + } else if path.is_file() { + Ok(FileSelectionAction::FileSelected( + path.to_string_lossy().to_string(), + )) + } else { + Ok(FileSelectionAction::Handled) + } + } else { + Ok(FileSelectionAction::Handled) + } + } else { + Ok(FileSelectionAction::Handled) + } + } + _ => Ok(FileSelectionAction::NotHandled), + } + } + + pub fn render(&mut self, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(frame.size()); + + // Header + let header = Paragraph::new(self.current_dir.display().to_string()) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title("Select Solution File"), + ); + frame.render_widget(header, chunks[0]); + + // File list + let items: Vec = self + .entries + .iter() + .enumerate() + .map(|(i, path)| { + let is_parent = + i == 0 && path.parent().is_some() && path.parent() != Some(&self.current_dir); + let display_name = if is_parent { + "../".to_string() + } else { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + if path.is_dir() { + format!("{}/", name) + } else { + name.to_string() + } + }; + + let style = if path.is_dir() { + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else if path.extension().and_then(|e| e.to_str()) == Some("py") { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + + ListItem::new(Line::from(Span::styled(display_name, style))) + }) + .collect(); + + let files = List::new(items) + .block(Block::default().borders(Borders::ALL)) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + + frame.render_stateful_widget(files, chunks[1], &mut self.state); + + // Footer + let footer_text = "↑/↓: Navigate | Enter: Select | q/Esc: Cancel"; + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + frame.render_widget(footer, chunks[2]); + } +} diff --git a/src/views/mod.rs b/src/views/mod.rs index a0e6eff..f50eaca 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,2 +1,5 @@ pub mod result_page; pub mod loading_page; +pub mod welcome_page; +pub mod ascii_art; +pub mod file_selection_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs index 14ad4ad..7e402b9 100644 --- a/src/views/result_page.rs +++ b/src/views/result_page.rs @@ -1,23 +1,161 @@ use crate::utils; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::{Alignment, Constraint, Layout, Margin, Rect}, prelude::Buffer, style::{Color, Style}, symbols::scrollbar, widgets::{Block, BorderType, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget}, + Frame, }; +#[derive(Debug, PartialEq)] +pub enum ResultAction { + Handled, + NotHandled, + Quit, +} + #[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, pub animation_frame: u16, } +pub struct ResultView { + result_text: String, +} + +impl ResultView { + pub fn new(result_text: String) -> Self { + Self { result_text } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> ResultAction { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => ResultAction::Quit, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => ResultAction::Quit, + KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up | + KeyCode::Char('h') | KeyCode::Left | KeyCode::Char('l') | KeyCode::Right => { + // Scrolling is handled by updating state + ResultAction::Handled + } + _ => ResultAction::NotHandled, + } + } + + pub fn update_scroll(&self, key: KeyEvent, state: &mut ResultPageState) { + 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); + } + _ => {} + } + } + + pub fn render(&self, frame: &mut Frame, state: &mut ResultPageState) { + // Initialize scroll state based on content + let max_width = self.result_text + .lines() + .map(|line| line.len()) + .max() + .unwrap_or(0); + + let num_lines = self.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); + + // Increment animation frame on every render + state.animation_frame = state.animation_frame.wrapping_add(1); + + let layout = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]); + let [left, right] = layout.areas(frame.size()); + + self.render_left(frame.buffer_mut(), left, state); + self.render_right(frame.buffer_mut(), 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, + }), + frame.buffer_mut(), + &mut state.vertical_scroll_state, + ); + horizontal_scrollbar.render( + right.inner(&Margin { + vertical: 0, + horizontal: 1, + }), + frame.buffer_mut(), + &mut state.horizontal_scroll_state, + ); + } + + fn render_left(&self, buf: &mut Buffer, left: Rect, state: &mut ResultPageState) { + let left_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Rgb(255, 165, 0))) + .title("GPU MODE") + .title_alignment(Alignment::Center); + + let left_text = Paragraph::new(utils::get_ascii_art_frame(state.animation_frame / 5)); + + 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::Rgb(255, 165, 0))) + .title_alignment(Alignment::Center) + .title("Submission Results") + .title_bottom("Press q to quit...") + .title_style(Style::default().fg(Color::Magenta)); + + let result_text = Paragraph::new(self.result_text.clone()) + .block(right_block) + .scroll((state.vertical_scroll as u16, state.horizontal_scroll as u16)); + result_text.render(right, buf); + } +} + #[derive(Default, Debug)] pub struct ResultPage { result_text: Paragraph<'static>, @@ -74,50 +212,6 @@ impl ResultPage { result_text.render(right, buf); } - pub fn handle_key_event(&mut self, state: &mut ResultPageState) { - // Use a non-blocking poll - if let Ok(true) = event::poll(std::time::Duration::from_millis(0)) { - if let Ok(Event::Key(key)) = event::read() { - if key.kind != KeyEventKind::Press { - return; - } - if key.code == KeyCode::Char('q') { - state.ack = true; - } - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - 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 { diff --git a/src/views/welcome_page.rs b/src/views/welcome_page.rs new file mode 100644 index 0000000..2b58199 --- /dev/null +++ b/src/views/welcome_page.rs @@ -0,0 +1,170 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + Frame, +}; +use crate::views::ascii_art::{AsciiArt, create_background_pattern}; + +// Color constants +pub const COLOR_TITLE: Color = Color::Rgb(218, 119, 86); // #da7756 - Orange +pub const COLOR_BACKGROUND: Color = Color::Rgb(139, 69, 19); // Dark orange/brown +pub const COLOR_SELECTED: Color = Color::Yellow; +pub const COLOR_UNSELECTED: Color = Color::Rgb(169, 169, 169); // Light gray + +// Layout constants +pub const TITLE_HEIGHT: u16 = 10; +pub const TITLE_SPACING: u16 = 3; +pub const MENU_ITEM_HEIGHT: u16 = 3; +pub const MENU_ITEM_SPACING: u16 = 2; +pub const HORIZONTAL_MARGIN: u16 = 20; // Percentage for centering + +#[derive(Debug, PartialEq)] +pub enum WelcomeAction { + Handled, + NotHandled, + Submit, + ViewHistory, +} + +pub struct WelcomeView { + selected_index: usize, +} + +impl WelcomeView { + pub fn new() -> Self { + Self { selected_index: 0 } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> WelcomeAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + WelcomeAction::Handled + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected_index < 1 { // We have 2 menu items (0 and 1) + self.selected_index += 1; + } + WelcomeAction::Handled + } + KeyCode::Enter => { + match self.selected_index { + 0 => WelcomeAction::Submit, + 1 => WelcomeAction::ViewHistory, + _ => WelcomeAction::Handled, + } + } + _ => WelcomeAction::NotHandled + } + } + + pub fn render(&self, frame: &mut Frame) { + // Create a retro background pattern + let bg_text = create_background_pattern(frame.size().width, frame.size().height); + let background = Paragraph::new(bg_text) + .style(Style::default().fg(COLOR_BACKGROUND)); + frame.render_widget(background, frame.size()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(TITLE_HEIGHT), + Constraint::Length(TITLE_SPACING), + Constraint::Min(10), + ] + .as_ref(), + ) + .split(frame.size()); + + // ASCII art title + let title_text = AsciiArt::kernelbot_title(); + let title_lines: Vec = title_text + .iter() + .map(|&line| { + Line::from(vec![Span::styled( + line, + Style::default() + .fg(COLOR_TITLE) + .add_modifier(Modifier::BOLD), + )]) + }) + .collect(); + + let title = Paragraph::new(title_lines) + .alignment(Alignment::Center) + .block(Block::default()); + + frame.render_widget(title, chunks[0]); + + // Menu + let menu_items = vec!["Submit", "View History"]; + let menu_area = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(MENU_ITEM_HEIGHT), + Constraint::Length(MENU_ITEM_SPACING), + Constraint::Length(MENU_ITEM_HEIGHT), + ] + .as_ref(), + ) + .split(chunks[2]); + + // Center the menu horizontally + let centered_menu_area: Vec<_> = menu_area + .iter() + .enumerate() + .filter_map(|(i, &area)| { + if i % 2 == 0 { + Some(Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(HORIZONTAL_MARGIN), + Constraint::Percentage(100 - 2 * HORIZONTAL_MARGIN), + Constraint::Percentage(HORIZONTAL_MARGIN), + ] + .as_ref(), + ) + .split(area)[1]) + } else { + None + } + }) + .collect(); + + // Render menu items + for (i, (item, area)) in menu_items.iter().zip(centered_menu_area.iter()).enumerate() { + let is_selected = i == self.selected_index; + + let menu_lines = match *item { + "Submit" => AsciiArt::submit_menu_item(is_selected), + "View History" => AsciiArt::history_menu_item(is_selected), + _ => continue, + }; + + let style = if is_selected { + Style::default() + .fg(COLOR_SELECTED) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(COLOR_UNSELECTED) + }; + + let menu_text = menu_lines.join("\n"); + let menu_item = Paragraph::new(menu_text) + .style(style) + .alignment(Alignment::Center); + + frame.render_widget(menu_item, *area); + } + } +}