From b122c489a1507c335ea6804ba595b04ade01cc0e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 02:11:58 +0200 Subject: [PATCH 1/7] Feat: initial screen --- src/cmd/mod.rs | 24 ++++- src/views/ascii_art.rs | 69 ++++++++++++++ src/views/mod.rs | 2 + src/views/welcome_page.rs | 183 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/views/ascii_art.rs create mode 100644 src/views/welcome_page.rs diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 617bfad..5b90086 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -158,7 +158,29 @@ pub async fn execute(cli: Cli) -> Result<()> { ) .await } else { - Err(anyhow!("No command or submission file specified. Use --help for usage.")) + // Show welcome screen + use crate::views::welcome_page::WelcomeScreen; + let mut welcome = WelcomeScreen::new(); + match welcome.run().await? { + Some(selection) => match selection.as_str() { + "Submit" => { + 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 + } + "View History" => { + Err(anyhow!("View History feature is not yet implemented")) + } + _ => Ok(()), + }, + None => Ok(()), // User quit + } } } } 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/mod.rs b/src/views/mod.rs index a0e6eff..0afb5fa 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,2 +1,4 @@ pub mod result_page; pub mod loading_page; +pub mod welcome_page; +pub mod ascii_art; diff --git a/src/views/welcome_page.rs b/src/views/welcome_page.rs new file mode 100644 index 0000000..176fb01 --- /dev/null +++ b/src/views/welcome_page.rs @@ -0,0 +1,183 @@ +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + Terminal, +}; +use std::io; +use crate::views::ascii_art::{AsciiArt, create_background_pattern}; + +pub struct WelcomeScreen { + selected_index: usize, + menu_items: Vec, +} + +impl WelcomeScreen { + pub fn new() -> Self { + Self { + selected_index: 0, + menu_items: vec!["Submit".to_string(), "View History".to_string()], + } + } + + pub async fn run(&mut self) -> io::Result> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = self.run_app(&mut terminal).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result + } + + async fn run_app( + &mut self, + terminal: &mut Terminal, + ) -> io::Result> { + loop { + terminal.draw(|f| self.ui(f))?; + + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Enter => { + return Ok(Some(self.menu_items[self.selected_index].clone())); + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected_index < self.menu_items.len() - 1 { + self.selected_index += 1; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + _ => {} + } + } + } + } + + fn ui(&self, f: &mut ratatui::Frame) { + // Create a retro background pattern + let bg_text = create_background_pattern(f.size().width, f.size().height); + let background = Paragraph::new(bg_text) + .style(Style::default().fg(Color::Rgb(139, 69, 19))); // Dark orange/brown + f.render_widget(background, f.size()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(10), // ASCII art title (increased for filled version) + Constraint::Length(3), // Spacing + Constraint::Min(5), // Menu + ] + .as_ref(), + ) + .split(f.size()); + + // ASCII art title - filled version + 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::Rgb(218, 119, 86)) // #da7756 + .add_modifier(Modifier::BOLD), + )]) + }) + .collect(); + + let title = Paragraph::new(title_lines) + .alignment(Alignment::Center) + .block(Block::default()); + + f.render_widget(title, chunks[0]); + + // Menu - arcade style with medium text + let menu_area = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), // First menu item (medium) + Constraint::Length(2), // Spacing + Constraint::Length(3), // Second menu item (medium) + ] + .as_ref(), + ) + .split(chunks[2]); + + // Center the menu horizontally + let centered_menu_area: Vec<_> = menu_area + .iter() + .map(|&area| { + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(20), + Constraint::Percentage(60), + Constraint::Percentage(20), + ] + .as_ref(), + ) + .split(area)[1] + }) + .collect(); + + // Render each menu item separately for arcade-style appearance + for (i, item) in self.menu_items.iter().enumerate() { + let area_index = i * 2; // Skip spacing constraints + if area_index < centered_menu_area.len() { + let is_selected = i == self.selected_index; + + // Get ASCII art for menu items + let menu_lines = match item.as_str() { + "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::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Rgb(169, 169, 169)) // Light gray + }; + + let menu_text = menu_lines.join("\n"); + let menu_item = Paragraph::new(menu_text) + .style(style) + .alignment(Alignment::Center); + + f.render_widget(menu_item, centered_menu_area[area_index]); + } + } + + } +} From cd3aebce2a215111d594610b48b6f4b3f168010c Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 02:16:30 +0200 Subject: [PATCH 2/7] Feat: refactored welcome screen --- src/views/welcome_page.rs | 176 +++++++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 69 deletions(-) diff --git a/src/views/welcome_page.rs b/src/views/welcome_page.rs index 176fb01..500312a 100644 --- a/src/views/welcome_page.rs +++ b/src/views/welcome_page.rs @@ -14,6 +14,19 @@ use ratatui::{ use std::io; use crate::views::ascii_art::{AsciiArt, create_background_pattern}; +// Color constants +const COLOR_TITLE: Color = Color::Rgb(218, 119, 86); // #da7756 - Orange +const COLOR_BACKGROUND: Color = Color::Rgb(139, 69, 19); // Dark orange/brown +const COLOR_SELECTED: Color = Color::Yellow; +const COLOR_UNSELECTED: Color = Color::Rgb(169, 169, 169); // Light gray + +// Layout constants +const TITLE_HEIGHT: u16 = 10; +const TITLE_SPACING: u16 = 3; +const MENU_ITEM_HEIGHT: u16 = 3; +const MENU_ITEM_SPACING: u16 = 2; +const HORIZONTAL_MARGIN: u16 = 20; // Percentage for centering + pub struct WelcomeScreen { selected_index: usize, menu_items: Vec, @@ -76,36 +89,70 @@ impl WelcomeScreen { } } - fn ui(&self, f: &mut ratatui::Frame) { - // Create a retro background pattern - let bg_text = create_background_pattern(f.size().width, f.size().height); - let background = Paragraph::new(bg_text) - .style(Style::default().fg(Color::Rgb(139, 69, 19))); // Dark orange/brown - f.render_widget(background, f.size()); - - let chunks = Layout::default() + fn create_centered_menu_areas(&self, menu_chunk: ratatui::layout::Rect) -> Vec { + // Create menu areas with spacing + let menu_areas = Layout::default() .direction(Direction::Vertical) - .margin(1) + .constraints( + self.menu_items + .iter() + .enumerate() + .flat_map(|(i, _)| { + if i == 0 { + vec![Constraint::Length(MENU_ITEM_HEIGHT)] + } else { + vec![Constraint::Length(MENU_ITEM_SPACING), Constraint::Length(MENU_ITEM_HEIGHT)] + } + }) + .collect::>(), + ) + .split(menu_chunk); + + // Center each menu area horizontally + menu_areas + .iter() + .enumerate() + .filter_map(|(i, &area)| { + // Only return the actual menu item areas, not the spacing + if i % 2 == 0 { + Some(self.center_horizontally(area)) + } else { + None + } + }) + .collect() + } + + fn center_horizontally(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect { + Layout::default() + .direction(Direction::Horizontal) .constraints( [ - Constraint::Length(10), // ASCII art title (increased for filled version) - Constraint::Length(3), // Spacing - Constraint::Min(5), // Menu + Constraint::Percentage(HORIZONTAL_MARGIN), + Constraint::Percentage(100 - 2 * HORIZONTAL_MARGIN), + Constraint::Percentage(HORIZONTAL_MARGIN), ] .as_ref(), ) - .split(f.size()); + .split(area)[1] + } - // ASCII art title - filled version - let title_text = AsciiArt::kernelbot_title(); + fn render_background(&self, f: &mut ratatui::Frame) { + let bg_text = create_background_pattern(f.size().width, f.size().height); + let background = Paragraph::new(bg_text) + .style(Style::default().fg(COLOR_BACKGROUND)); + f.render_widget(background, f.size()); + } + fn render_title(&self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) { + 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::Rgb(218, 119, 86)) // #da7756 + .fg(COLOR_TITLE) .add_modifier(Modifier::BOLD), )]) }) @@ -115,69 +162,60 @@ impl WelcomeScreen { .alignment(Alignment::Center) .block(Block::default()); - f.render_widget(title, chunks[0]); + f.render_widget(title, area); + } - // Menu - arcade style with medium text - let menu_area = Layout::default() + fn render_menu_item(&self, f: &mut ratatui::Frame, item: &str, area: ratatui::layout::Rect, is_selected: bool) { + let menu_lines = match item { + "Submit" => AsciiArt::submit_menu_item(is_selected), + "View History" => AsciiArt::history_menu_item(is_selected), + _ => return, + }; + + 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); + + f.render_widget(menu_item, area); + } + + fn ui(&self, f: &mut ratatui::Frame) { + // Render background + self.render_background(f); + + // Create main layout + let chunks = Layout::default() .direction(Direction::Vertical) + .margin(1) .constraints( [ - Constraint::Length(3), // First menu item (medium) - Constraint::Length(2), // Spacing - Constraint::Length(3), // Second menu item (medium) + Constraint::Length(TITLE_HEIGHT), + Constraint::Length(TITLE_SPACING), + Constraint::Min(5), // Menu ] .as_ref(), ) - .split(chunks[2]); - - // Center the menu horizontally - let centered_menu_area: Vec<_> = menu_area - .iter() - .map(|&area| { - Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(20), - Constraint::Percentage(60), - Constraint::Percentage(20), - ] - .as_ref(), - ) - .split(area)[1] - }) - .collect(); + .split(f.size()); - // Render each menu item separately for arcade-style appearance - for (i, item) in self.menu_items.iter().enumerate() { - let area_index = i * 2; // Skip spacing constraints - if area_index < centered_menu_area.len() { - let is_selected = i == self.selected_index; - - // Get ASCII art for menu items - let menu_lines = match item.as_str() { - "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::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Rgb(169, 169, 169)) // Light gray - }; + // Render title + self.render_title(f, chunks[0]); - let menu_text = menu_lines.join("\n"); - let menu_item = Paragraph::new(menu_text) - .style(style) - .alignment(Alignment::Center); + // Create centered menu areas + let menu_areas = self.create_centered_menu_areas(chunks[2]); - f.render_widget(menu_item, centered_menu_area[area_index]); - } + // Render menu items + for (i, (item, area)) in self.menu_items.iter().zip(menu_areas.iter()).enumerate() { + self.render_menu_item(f, item, *area, i == self.selected_index); } - } } From 027798c6ff4129807bb63a65eccbf5ed2f269c8a Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 02:21:19 +0200 Subject: [PATCH 3/7] Feat: file selection --- src/cmd/submit.rs | 12 +- src/views/file_selection_page.rs | 220 +++++++++++++++++++++++++++++++ src/views/mod.rs | 1 + 3 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 src/views/file_selection_page.rs diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 83411be..9c33284 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -527,11 +527,13 @@ pub async fn run_submit_tui( 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() + // Use file selection TUI when no filepath is provided + use crate::views::file_selection_page::FileSelectionScreen; + let mut file_selector = FileSelectionScreen::new()?; + match file_selector.run().await? { + Some(selected_file) => selected_file, + None => return Ok(()), // User cancelled + } } }; diff --git a/src/views/file_selection_page.rs b/src/views/file_selection_page.rs new file mode 100644 index 0000000..e239c62 --- /dev/null +++ b/src/views/file_selection_page.rs @@ -0,0 +1,220 @@ +use crossterm::{ + event::{self, Event, KeyCode}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + execute, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Terminal, +}; +use std::io; +use std::path::PathBuf; +use std::fs; + +pub struct FileSelectionScreen { + current_dir: PathBuf, + entries: Vec, + state: ListState, + show_hidden: bool, +} + +impl FileSelectionScreen { + pub fn new() -> io::Result { + let current_dir = std::env::current_dir()?; + let mut screen = Self { + current_dir: current_dir.clone(), + entries: Vec::new(), + state: ListState::default(), + show_hidden: false, + }; + screen.load_directory()?; + screen.state.select(Some(0)); + Ok(screen) + } + + fn load_directory(&mut self) -> io::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| { + if self.show_hidden { + true + } else { + 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 async fn run(&mut self) -> io::Result> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = self.run_app(&mut terminal).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + result + } + + async fn run_app( + &mut self, + terminal: &mut Terminal, + ) -> io::Result> { + loop { + terminal.draw(|f| self.ui(f))?; + + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + 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)); + } else if path.is_file() { + return Ok(Some(path.to_string_lossy().to_string())); + } + } + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(selected) = self.state.selected() { + if selected > 0 { + self.state.select(Some(selected - 1)); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.state.selected() { + if selected < self.entries.len() - 1 { + self.state.select(Some(selected + 1)); + } + } + } + KeyCode::Char('h') => { + self.show_hidden = !self.show_hidden; + self.load_directory()?; + self.state.select(Some(0)); + } + _ => {} + } + } + } + } + + fn ui(&self, f: &mut ratatui::Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.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")); + f.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("> "); + + f.render_stateful_widget(files, chunks[1], &mut self.state.clone()); + + // Footer + let footer_text = if self.show_hidden { + "↑/↓: Navigate | Enter: Select | h: Hide hidden files | q/Esc: Cancel" + } else { + "↑/↓: Navigate | Enter: Select | h: Show hidden files | q/Esc: Cancel" + }; + let footer = Paragraph::new(footer_text) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + f.render_widget(footer, chunks[2]); + } +} \ No newline at end of file diff --git a/src/views/mod.rs b/src/views/mod.rs index 0afb5fa..f50eaca 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,3 +2,4 @@ pub mod result_page; pub mod loading_page; pub mod welcome_page; pub mod ascii_art; +pub mod file_selection_page; From 29fdbff1c509516fd66769827935a316b62c1259 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 03:06:11 +0200 Subject: [PATCH 4/7] Feat: more refactor --- src/cmd/mod.rs | 33 +- src/cmd/submit.rs | 592 +++++++++++++++++-------------- src/models/mod.rs | 64 ++++ src/views/file_selection_page.rs | 154 ++++---- src/views/mod.rs | 1 + src/views/selection.rs | 92 +++++ src/views/welcome_page.rs | 273 ++++++-------- 7 files changed, 674 insertions(+), 535 deletions(-) create mode 100644 src/views/selection.rs diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 5b90086..42d6b48 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -158,29 +158,16 @@ pub async fn execute(cli: Cli) -> Result<()> { ) .await } else { - // Show welcome screen - use crate::views::welcome_page::WelcomeScreen; - let mut welcome = WelcomeScreen::new(); - match welcome.run().await? { - Some(selection) => match selection.as_str() { - "Submit" => { - 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 - } - "View History" => { - Err(anyhow!("View History feature is not yet implemented")) - } - _ => Ok(()), - }, - None => Ok(()), // User quit - } + // 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 9c33284..e2a39b4 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -6,18 +6,129 @@ 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::views::selection::SelectionItem; use crate::service; use crate::utils; use crate::views::loading_page::{LoadingPage, LoadingPageState}; use crate::views::result_page::{ResultPage, ResultPageState}; +use crate::views::welcome_page::{WelcomeView, WelcomeAction}; +use crate::views::file_selection_page::{FileSelectionView, FileSelectionAction}; +use crate::views::selection::{SelectionView, SelectionAction}; + +// 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, +} -#[derive(Default, Debug)] +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 + } +} + +#[derive(Default)] pub struct App { pub filepath: String, pub cli_id: String, @@ -43,12 +154,18 @@ pub struct App { 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, } 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 +190,25 @@ 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, ..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)); @@ -127,76 +256,116 @@ impl App { } 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); - } - 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 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.set_error_and_quit("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; + self.app_state = AppState::LeaderboardSelection; + if let Err(e) = self.spawn_load_leaderboards() { + self.set_error_and_quit(format!("Error starting leaderboard fetch: {}", e)); + } + return Ok(true); + } + FileSelectionAction::Handled => return Ok(true), + FileSelectionAction::NotHandled => return Ok(false), + _ => return Ok(true), + } + } + } + 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.set_error_and_quit(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.set_error_and_quit(format!("Error starting submission: {}", e)); } return Ok(true); } + SelectionAction::Handled => return Ok(true), + SelectionAction::NotHandled => return Ok(false), } } - _ => {} - }, - KeyCode::Up => { - self.move_selection_up(); - return Ok(true); - } - KeyCode::Down => { - self.move_selection_down(); - return Ok(true); } _ => {} } + Ok(false) } @@ -205,60 +374,6 @@ impl App { 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)); - } - } - } - AppState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx > 0 { - self.submission_modes_state.select(Some(idx - 1)); - } - } - } - _ => {} - } - } - - 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)); - } - } - } - _ => {} - } - } - pub fn spawn_load_leaderboards(&mut self) -> Result<()> { let client = service::create_client(Some(self.cli_id.clone()))?; self.leaderboards_task = Some(tokio::spawn(async move { @@ -313,33 +428,41 @@ 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.set_error_and_quit(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)) => { @@ -357,22 +480,36 @@ 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)), @@ -399,118 +536,38 @@ impl App { } } -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(), ) } @@ -525,71 +582,66 @@ pub async fn run_submit_tui( cli_id: String, ) -> Result<()> { let file_to_submit = match filepath { - Some(fp) => fp, - None => { - // Use file selection TUI when no filepath is provided - use crate::views::file_selection_page::FileSelectionScreen; - let mut file_selector = FileSelectionScreen::new()?; - match file_selector.run().await? { - Some(selected_file) => selected_file, - None => return Ok(()), // User cancelled + 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 mut app = App::new(file_to_submit.clone(), cli_id); - let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; - - 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); + if has_multiple_gpus { + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); + } - // 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; + // 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 no CLI flags, use directives - if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { - app.initialize_with_directives(directives); - } + // If no CLI flags, use directives + if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { + 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)); + // 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::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)); + AppState::WaitingForResult => { + if let Err(e) = app.spawn_submit_solution() { + return Err(anyhow!("Error starting submission: {}", e)); + } } + _ => {} } - _ => {} } // Now, set up the TUI @@ -600,7 +652,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; @@ -658,7 +710,5 @@ pub async fn run_submit_tui( )?; terminal.show_cursor()?; - // utils::display_ascii_art(); - Ok(()) -} +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a9c8a8..89afa32 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,8 @@ use serde::{Deserialize, Serialize}; +use ratatui::widgets::ListItem; +use ratatui::text::{Line, Span}; +use ratatui::style::{Style, Color}; +use crate::views::selection::SelectionItem; #[derive(Clone, Debug)] pub struct LeaderboardItem { @@ -46,6 +50,8 @@ impl SubmissionModeItem { #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum AppState { #[default] + Welcome, + FileSelection, LeaderboardSelection, GpuSelection, SubmissionModeSelection, @@ -54,3 +60,61 @@ pub enum AppState { #[derive(Debug, Serialize, Deserialize)] pub struct SubmissionResultMsg(pub String); + +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)), + ]); + + let description_line = Line::from(vec![ + Span::styled(self.task_description.clone(), Style::default().fg(Color::Gray)), + ]); + + ListItem::new(vec![title_line, description_line]) + } +} + +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)), + ]); + + let description_line = Line::from(vec![ + Span::styled(self.description_text.clone(), Style::default().fg(Color::Gray)), + ]); + + ListItem::new(vec![title_line, description_line]) + } +} diff --git a/src/views/file_selection_page.rs b/src/views/file_selection_page.rs index e239c62..c18569b 100644 --- a/src/views/file_selection_page.rs +++ b/src/views/file_selection_page.rs @@ -1,42 +1,53 @@ -use crossterm::{ - event::{self, Event, KeyCode}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - execute, -}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Terminal, + Frame, }; -use std::io; use std::path::PathBuf; use std::fs; +use anyhow::Result; + +#[derive(Debug, PartialEq)] +pub enum FileSelectionAction { + Handled, + NotHandled, + FileSelected(String), + ToggleHidden, +} -pub struct FileSelectionScreen { +pub struct FileSelectionView { current_dir: PathBuf, entries: Vec, state: ListState, show_hidden: bool, } -impl FileSelectionScreen { - pub fn new() -> io::Result { +impl FileSelectionView { + pub fn new() -> Result { let current_dir = std::env::current_dir()?; - let mut screen = Self { - current_dir: current_dir.clone(), + let mut view = Self { + current_dir, entries: Vec::new(), state: ListState::default(), show_hidden: false, }; - screen.load_directory()?; - screen.state.select(Some(0)); - Ok(screen) + view.load_directory()?; + view.state.select(Some(0)); + Ok(view) + } + + pub fn current_dir(&self) -> &PathBuf { + &self.current_dir + } + + pub fn show_hidden(&self) -> bool { + self.show_hidden } - fn load_directory(&mut self) -> io::Result<()> { + pub fn load_directory(&mut self) -> Result<()> { self.entries.clear(); // Add parent directory option @@ -75,75 +86,56 @@ impl FileSelectionScreen { Ok(()) } - pub async fn run(&mut self) -> io::Result> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.run_app(&mut terminal).await; - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen - )?; - terminal.show_cursor()?; - - result - } - - async fn run_app( - &mut self, - terminal: &mut Terminal, - ) -> io::Result> { - loop { - terminal.draw(|f| self.ui(f))?; - - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(None), - 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)); - } else if path.is_file() { - return Ok(Some(path.to_string_lossy().to_string())); - } - } - } + 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)); } - 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)); } - KeyCode::Down | KeyCode::Char('j') => { - if let Some(selected) = self.state.selected() { - if selected < self.entries.len() - 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) } - KeyCode::Char('h') => { - self.show_hidden = !self.show_hidden; - self.load_directory()?; - self.state.select(Some(0)); - } - _ => {} + } else { + Ok(FileSelectionAction::Handled) } } + KeyCode::Char('h') => { + self.show_hidden = !self.show_hidden; + self.load_directory()?; + self.state.select(Some(0)); + Ok(FileSelectionAction::ToggleHidden) + } + _ => Ok(FileSelectionAction::NotHandled) } } - fn ui(&self, f: &mut ratatui::Frame) { + pub fn render(&mut self, frame: &mut Frame) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) @@ -155,14 +147,14 @@ impl FileSelectionScreen { ] .as_ref(), ) - .split(f.size()); + .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")); - f.render_widget(header, chunks[0]); + frame.render_widget(header, chunks[0]); // File list let items: Vec = self.entries @@ -204,7 +196,7 @@ impl FileSelectionScreen { ) .highlight_symbol("> "); - f.render_stateful_widget(files, chunks[1], &mut self.state.clone()); + frame.render_stateful_widget(files, chunks[1], &mut self.state); // Footer let footer_text = if self.show_hidden { @@ -215,6 +207,6 @@ impl FileSelectionScreen { let footer = Paragraph::new(footer_text) .style(Style::default().fg(Color::DarkGray)) .alignment(Alignment::Center); - f.render_widget(footer, chunks[2]); + frame.render_widget(footer, chunks[2]); } } \ No newline at end of file diff --git a/src/views/mod.rs b/src/views/mod.rs index f50eaca..9605bd8 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -3,3 +3,4 @@ pub mod loading_page; pub mod welcome_page; pub mod ascii_art; pub mod file_selection_page; +pub mod selection; diff --git a/src/views/selection.rs b/src/views/selection.rs new file mode 100644 index 0000000..e9ebf12 --- /dev/null +++ b/src/views/selection.rs @@ -0,0 +1,92 @@ +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Style}, + widgets::{Block, Borders, List, ListItem, ListState}, + Frame, +}; + +pub trait SelectionItem { + fn title(&self) -> &str; + 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 + let list = List::new(list_items) + .block(Block::default().borders(Borders::ALL).title(title.clone())) + .style(Style::default().fg(Color::White)) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + + frame.render_stateful_widget(list, layout_area, self.state_mut()); + } +} + +#[derive(Debug, PartialEq)] +pub enum SelectionAction { + Handled, + NotHandled, + Selected(usize), +} diff --git a/src/views/welcome_page.rs b/src/views/welcome_page.rs index 500312a..091d6ce 100644 --- a/src/views/welcome_page.rs +++ b/src/views/welcome_page.rs @@ -1,150 +1,93 @@ -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ - backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Paragraph}, - Terminal, + Frame, }; -use std::io; use crate::views::ascii_art::{AsciiArt, create_background_pattern}; // Color constants -const COLOR_TITLE: Color = Color::Rgb(218, 119, 86); // #da7756 - Orange -const COLOR_BACKGROUND: Color = Color::Rgb(139, 69, 19); // Dark orange/brown -const COLOR_SELECTED: Color = Color::Yellow; -const COLOR_UNSELECTED: Color = Color::Rgb(169, 169, 169); // Light gray +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 -const TITLE_HEIGHT: u16 = 10; -const TITLE_SPACING: u16 = 3; -const MENU_ITEM_HEIGHT: u16 = 3; -const MENU_ITEM_SPACING: u16 = 2; -const HORIZONTAL_MARGIN: u16 = 20; // Percentage for centering +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 WelcomeScreen { +pub struct WelcomeView { selected_index: usize, - menu_items: Vec, } -impl WelcomeScreen { +impl WelcomeView { pub fn new() -> Self { - Self { - selected_index: 0, - menu_items: vec!["Submit".to_string(), "View History".to_string()], - } + Self { selected_index: 0 } } - pub async fn run(&mut self) -> io::Result> { - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.run_app(&mut terminal).await; - - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - result + pub fn selected_index(&self) -> usize { + self.selected_index } - async fn run_app( - &mut self, - terminal: &mut Terminal, - ) -> io::Result> { - loop { - terminal.draw(|f| self.ui(f))?; - - if let Event::Key(key) = event::read()? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(None), - KeyCode::Enter => { - return Ok(Some(self.menu_items[self.selected_index].clone())); - } - KeyCode::Down | KeyCode::Char('j') => { - if self.selected_index < self.menu_items.len() - 1 { - self.selected_index += 1; - } - } - KeyCode::Up | KeyCode::Char('k') => { - if self.selected_index > 0 { - self.selected_index -= 1; - } - } - _ => {} + 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 } } - fn create_centered_menu_areas(&self, menu_chunk: ratatui::layout::Rect) -> Vec { - // Create menu areas with spacing - let menu_areas = Layout::default() - .direction(Direction::Vertical) - .constraints( - self.menu_items - .iter() - .enumerate() - .flat_map(|(i, _)| { - if i == 0 { - vec![Constraint::Length(MENU_ITEM_HEIGHT)] - } else { - vec![Constraint::Length(MENU_ITEM_SPACING), Constraint::Length(MENU_ITEM_HEIGHT)] - } - }) - .collect::>(), - ) - .split(menu_chunk); - - // Center each menu area horizontally - menu_areas - .iter() - .enumerate() - .filter_map(|(i, &area)| { - // Only return the actual menu item areas, not the spacing - if i % 2 == 0 { - Some(self.center_horizontally(area)) - } else { - None - } - }) - .collect() - } + 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()); - fn center_horizontally(&self, area: ratatui::layout::Rect) -> ratatui::layout::Rect { - Layout::default() - .direction(Direction::Horizontal) + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) .constraints( [ - Constraint::Percentage(HORIZONTAL_MARGIN), - Constraint::Percentage(100 - 2 * HORIZONTAL_MARGIN), - Constraint::Percentage(HORIZONTAL_MARGIN), + Constraint::Length(TITLE_HEIGHT), + Constraint::Length(TITLE_SPACING), + Constraint::Min(10), ] .as_ref(), ) - .split(area)[1] - } - - fn render_background(&self, f: &mut ratatui::Frame) { - let bg_text = create_background_pattern(f.size().width, f.size().height); - let background = Paragraph::new(bg_text) - .style(Style::default().fg(COLOR_BACKGROUND)); - f.render_widget(background, f.size()); - } + .split(frame.size()); - fn render_title(&self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) { + // ASCII art title let title_text = AsciiArt::kernelbot_title(); let title_lines: Vec = title_text .iter() @@ -162,60 +105,70 @@ impl WelcomeScreen { .alignment(Alignment::Center) .block(Block::default()); - f.render_widget(title, area); - } - - fn render_menu_item(&self, f: &mut ratatui::Frame, item: &str, area: ratatui::layout::Rect, is_selected: bool) { - let menu_lines = match item { - "Submit" => AsciiArt::submit_menu_item(is_selected), - "View History" => AsciiArt::history_menu_item(is_selected), - _ => return, - }; - - 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); - - f.render_widget(menu_item, area); - } + frame.render_widget(title, chunks[0]); - fn ui(&self, f: &mut ratatui::Frame) { - // Render background - self.render_background(f); - - // Create main layout - let chunks = Layout::default() + // Menu + let menu_items = vec!["Submit", "View History"]; + let menu_area = Layout::default() .direction(Direction::Vertical) - .margin(1) .constraints( [ - Constraint::Length(TITLE_HEIGHT), - Constraint::Length(TITLE_SPACING), - Constraint::Min(5), // Menu + Constraint::Length(MENU_ITEM_HEIGHT), + Constraint::Length(MENU_ITEM_SPACING), + Constraint::Length(MENU_ITEM_HEIGHT), ] .as_ref(), ) - .split(f.size()); + .split(chunks[2]); - // Render title - self.render_title(f, chunks[0]); - - // Create centered menu areas - let menu_areas = self.create_centered_menu_areas(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 self.menu_items.iter().zip(menu_areas.iter()).enumerate() { - self.render_menu_item(f, item, *area, i == self.selected_index); + 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); } } -} +} \ No newline at end of file From cd9396e1502a546ebe5d0a3885f9d29fa3221936 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 03:16:12 +0200 Subject: [PATCH 5/7] Some stuff works --- src/cmd/submit.rs | 110 ++++++++++++----------- src/models/mod.rs | 1 + src/views/result_page.rs | 186 +++++++++++++++++++++++++++++---------- 3 files changed, 199 insertions(+), 98 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index e2a39b4..e598705 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -14,7 +14,7 @@ use crate::views::selection::SelectionItem; use crate::service; use crate::utils; use crate::views::loading_page::{LoadingPage, LoadingPageState}; -use crate::views::result_page::{ResultPage, ResultPageState}; +use crate::views::result_page::{ResultPageState, ResultView, ResultAction}; use crate::views::welcome_page::{WelcomeView, WelcomeAction}; use crate::views::file_selection_page::{FileSelectionView, FileSelectionAction}; use crate::views::selection::{SelectionView, SelectionAction}; @@ -146,8 +146,6 @@ 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>>>, @@ -162,6 +160,7 @@ pub struct App { pub leaderboard_view: Option, pub gpu_view: Option, pub submission_mode_view: Option, + pub result_view: Option, } impl App { @@ -199,6 +198,7 @@ impl App { leaderboard_view: None, gpu_view: None, submission_mode_view: None, + result_view: None, ..Default::default() }; @@ -278,7 +278,7 @@ impl App { return Ok(true); } WelcomeAction::ViewHistory => { - self.set_error_and_quit("View History feature is not yet implemented".to_string()); + self.show_error("View History feature is not yet implemented".to_string()); return Ok(true); } WelcomeAction::Handled => return Ok(true), @@ -293,7 +293,7 @@ impl App { self.filepath = filepath; self.app_state = AppState::LeaderboardSelection; if let Err(e) = self.spawn_load_leaderboards() { - self.set_error_and_quit(format!("Error starting leaderboard fetch: {}", e)); + self.show_error(format!("Error starting leaderboard fetch: {}", e)); } return Ok(true); } @@ -312,7 +312,7 @@ impl App { 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; @@ -354,7 +354,7 @@ impl App { 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); } @@ -363,15 +363,31 @@ impl App { } } } + 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 set_error_and_quit(&mut self, error_message: String) { - self.final_status = Some(error_message); - self.should_quit = true; + 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<()> { @@ -450,7 +466,7 @@ impl App { } 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; } } @@ -466,9 +482,9 @@ impl App { } } Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) + self.show_error(format!("Error fetching leaderboards: {}", e)) } - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + Err(e) => self.show_error(format!("Task join error: {}", e)), } } } @@ -512,8 +528,8 @@ impl App { 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)), } } } @@ -525,11 +541,29 @@ 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)), } } } @@ -571,6 +605,11 @@ pub fn ui(app: &mut App, frame: &mut Frame) { &mut app.loading_page_state.clone(), ) } + AppState::ShowingResult => { + if let Some(view) = &mut app.result_view { + view.render(frame, &mut app.result_page_state); + } + } } } @@ -669,39 +708,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!( diff --git a/src/models/mod.rs b/src/models/mod.rs index 89afa32..13bfcb8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -56,6 +56,7 @@ pub enum AppState { GpuSelection, SubmissionModeSelection, WaitingForResult, + ShowingResult, } #[derive(Debug, Serialize, Deserialize)] 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 { From 934c627c16420047944287b07bbae22ef2e244cc Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 03:24:41 +0200 Subject: [PATCH 6/7] As the default works --- src/cmd/submit.rs | 90 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index e598705..2679f14 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -233,25 +233,40 @@ 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(); - if !popcorn_directives.gpus.is_empty() { - self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + // 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()); + } + + // 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; } } @@ -290,10 +305,41 @@ impl App { if let Some(view) = &mut self.file_selection_view { match view.handle_key_event(key)? { FileSelectionAction::FileSelected(filepath) => { - self.filepath = filepath; - self.app_state = AppState::LeaderboardSelection; - if let Err(e) = self.spawn_load_leaderboards() { - self.show_error(format!("Error starting leaderboard fetch: {}", e)); + 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 + 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); } @@ -642,7 +688,10 @@ pub async fn run_submit_tui( )); } - // Override directives with CLI flags if provided + // First apply directives as defaults + app.initialize_with_directives(directives); + + // Then override with CLI flags if provided if let Some(gpu_flag) = gpu { app.selected_gpu = Some(gpu_flag); } @@ -657,11 +706,6 @@ pub async fn run_submit_tui( } } - // If no CLI flags, use directives - if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { - app.initialize_with_directives(directives); - } - // Spawn the initial task based on the starting state BEFORE setting up the TUI match app.app_state { AppState::LeaderboardSelection => { From 6b88f86dbb06d943e2fca093741f90fcaf7ad58e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 20 Jun 2025 03:50:32 +0200 Subject: [PATCH 7/7] Feat: refactor-ished --- src/cmd/submit.rs | 191 +++++++----------------- src/models/mod.rs | 244 +++++++++++++++++++++++++++++-- src/utils/mod.rs | 50 +++---- src/views/file_selection_page.rs | 77 ++++------ src/views/mod.rs | 1 - src/views/selection.rs | 92 ------------ src/views/welcome_page.rs | 6 +- 7 files changed, 338 insertions(+), 323 deletions(-) delete mode 100644 src/views/selection.rs diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 2679f14..e39c43e 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -9,124 +9,16 @@ use ratatui::prelude::*; use ratatui::widgets::ListState; use tokio::task::JoinHandle; -use crate::models::{AppState, GpuItem, LeaderboardItem, SubmissionModeItem}; -use crate::views::selection::SelectionItem; +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::{ResultPageState, ResultView, ResultAction}; -use crate::views::welcome_page::{WelcomeView, WelcomeAction}; -use crate::views::file_selection_page::{FileSelectionView, FileSelectionAction}; -use crate::views::selection::{SelectionView, SelectionAction}; - -// 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 - } -} +use crate::views::result_page::{ResultAction, ResultPageState, ResultView}; +use crate::views::welcome_page::{WelcomeAction, WelcomeView}; #[derive(Default)] pub struct App { @@ -293,7 +185,9 @@ impl App { return Ok(true); } WelcomeAction::ViewHistory => { - self.show_error("View History feature is not yet implemented".to_string()); + self.show_error( + "View History feature is not yet implemented".to_string(), + ); return Ok(true); } WelcomeAction::Handled => return Ok(true), @@ -306,7 +200,7 @@ impl App { 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)) => { @@ -314,20 +208,27 @@ impl App { 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)); + 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)); + self.show_error(format!( + "Error starting GPU fetch: {}", + e + )); } } AppState::SubmissionModeSelection => { @@ -337,7 +238,10 @@ impl App { } } Err(e) => { - self.show_error(format!("Error parsing file directives: {}", e)); + self.show_error(format!( + "Error parsing file directives: {}", + e + )); return Ok(true); } } @@ -345,7 +249,6 @@ impl App { } FileSelectionAction::Handled => return Ok(true), FileSelectionAction::NotHandled => return Ok(false), - _ => return Ok(true), } } } @@ -354,7 +257,7 @@ impl App { 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() { @@ -492,7 +395,7 @@ impl App { Ok(Ok(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 @@ -504,11 +407,12 @@ impl App { } 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(), - )); + 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() { @@ -527,9 +431,7 @@ impl App { view.state_mut().select(Some(0)); } } - Ok(Err(e)) => { - self.show_error(format!("Error fetching leaderboards: {}", e)) - } + Ok(Err(e)) => self.show_error(format!("Error fetching leaderboards: {}", e)), Err(e) => self.show_error(format!("Task join error: {}", e)), } } @@ -545,9 +447,12 @@ impl App { self.gpus = gpus.clone(); self.gpu_view = Some(GpuSelectionView::new( gpus, - self.selected_leaderboard.as_ref().unwrap_or(&"N/A".to_string()).clone(), + 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 @@ -589,24 +494,28 @@ impl App { Ok(Ok(status)) => { // Process the status text let trimmed = status.trim(); - let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + 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.result_view = + Some(ResultView::new(format!("Submission error: {}", e))); self.app_state = AppState::ShowingResult; } Err(e) => { - // Show task join error in result view + // Show task join error in result view self.result_view = Some(ResultView::new(format!("Task join error: {}", e))); self.app_state = AppState::ShowingResult; } @@ -690,7 +599,7 @@ pub async fn run_submit_tui( // First apply directives as defaults app.initialize_with_directives(directives); - + // Then override with CLI flags if provided if let Some(gpu_flag) = gpu { app.selected_gpu = Some(gpu_flag); @@ -761,4 +670,4 @@ pub async fn run_submit_tui( terminal.show_cursor()?; Ok(()) -} \ No newline at end of file +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 13bfcb8..a0cdb28 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,8 +1,13 @@ use serde::{Deserialize, Serialize}; -use ratatui::widgets::ListItem; +use ratatui::widgets::{ListItem, Block, Borders, List, ListState}; use ratatui::text::{Line, Span}; use ratatui::style::{Style, Color}; -use crate::views::selection::SelectionItem; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + Frame, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use crate::utils; #[derive(Clone, Debug)] pub struct LeaderboardItem { @@ -62,6 +67,98 @@ pub enum AppState { #[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 @@ -71,16 +168,23 @@ impl SelectionItem for LeaderboardItem { Some(&self.task_description) } - fn to_list_item(&self, _available_width: usize) -> ListItem { + 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)), ]); - let description_line = Line::from(vec![ - Span::styled(self.task_description.clone(), Style::default().fg(Color::Gray)), - ]); + // 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(vec![title_line, description_line]) + ListItem::new(lines) } } @@ -107,15 +211,131 @@ impl SelectionItem for SubmissionModeItem { Some(&self.description_text) } - fn to_list_item(&self, _available_width: usize) -> ListItem { + 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)), ]); - let description_line = Line::from(vec![ - Span::styled(self.description_text.clone(), Style::default().fg(Color::Gray)), - ]); + // 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); - ListItem::new(vec![title_line, description_line]) + 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/file_selection_page.rs b/src/views/file_selection_page.rs index c18569b..1c9f9a6 100644 --- a/src/views/file_selection_page.rs +++ b/src/views/file_selection_page.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout}, @@ -6,23 +7,20 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, Frame, }; -use std::path::PathBuf; use std::fs; -use anyhow::Result; +use std::path::PathBuf; #[derive(Debug, PartialEq)] pub enum FileSelectionAction { Handled, NotHandled, FileSelected(String), - ToggleHidden, } pub struct FileSelectionView { current_dir: PathBuf, entries: Vec, state: ListState, - show_hidden: bool, } impl FileSelectionView { @@ -32,24 +30,15 @@ impl FileSelectionView { current_dir, entries: Vec::new(), state: ListState::default(), - show_hidden: false, }; view.load_directory()?; view.state.select(Some(0)); Ok(view) } - pub fn current_dir(&self) -> &PathBuf { - &self.current_dir - } - - pub fn show_hidden(&self) -> bool { - self.show_hidden - } - 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()); @@ -60,14 +49,10 @@ impl FileSelectionView { .filter_map(|entry| entry.ok()) .map(|entry| entry.path()) .filter(|path| { - if self.show_hidden { - true - } else { - path.file_name() - .and_then(|name| name.to_str()) - .map(|name| !name.starts_with('.')) - .unwrap_or(true) - } + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| !name.starts_with('.')) + .unwrap_or(true) }) .collect(); @@ -114,7 +99,9 @@ impl FileSelectionView { self.state.select(Some(0)); Ok(FileSelectionAction::Handled) } else if path.is_file() { - Ok(FileSelectionAction::FileSelected(path.to_string_lossy().to_string())) + Ok(FileSelectionAction::FileSelected( + path.to_string_lossy().to_string(), + )) } else { Ok(FileSelectionAction::Handled) } @@ -125,13 +112,7 @@ impl FileSelectionView { Ok(FileSelectionAction::Handled) } } - KeyCode::Char('h') => { - self.show_hidden = !self.show_hidden; - self.load_directory()?; - self.state.select(Some(0)); - Ok(FileSelectionAction::ToggleHidden) - } - _ => Ok(FileSelectionAction::NotHandled) + _ => Ok(FileSelectionAction::NotHandled), } } @@ -151,23 +132,31 @@ impl FileSelectionView { // Header let header = Paragraph::new(self.current_dir.display().to_string()) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title("Select Solution File")); + .block( + Block::default() + .borders(Borders::ALL) + .title("Select Solution File"), + ); frame.render_widget(header, chunks[0]); // File list - let items: Vec = self.entries + 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 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("?"); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); if path.is_dir() { format!("{}/", name) } else { @@ -176,7 +165,9 @@ impl FileSelectionView { }; let style = if path.is_dir() { - Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD) + 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 { @@ -192,21 +183,17 @@ impl FileSelectionView { .highlight_style( Style::default() .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::BOLD), ) .highlight_symbol("> "); frame.render_stateful_widget(files, chunks[1], &mut self.state); // Footer - let footer_text = if self.show_hidden { - "↑/↓: Navigate | Enter: Select | h: Hide hidden files | q/Esc: Cancel" - } else { - "↑/↓: Navigate | Enter: Select | h: Show hidden files | q/Esc: Cancel" - }; + 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]); } -} \ No newline at end of file +} diff --git a/src/views/mod.rs b/src/views/mod.rs index 9605bd8..f50eaca 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -3,4 +3,3 @@ pub mod loading_page; pub mod welcome_page; pub mod ascii_art; pub mod file_selection_page; -pub mod selection; diff --git a/src/views/selection.rs b/src/views/selection.rs deleted file mode 100644 index e9ebf12..0000000 --- a/src/views/selection.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Color, Style}, - widgets::{Block, Borders, List, ListItem, ListState}, - Frame, -}; - -pub trait SelectionItem { - fn title(&self) -> &str; - 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 - let list = List::new(list_items) - .block(Block::default().borders(Borders::ALL).title(title.clone())) - .style(Style::default().fg(Color::White)) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - - frame.render_stateful_widget(list, layout_area, self.state_mut()); - } -} - -#[derive(Debug, PartialEq)] -pub enum SelectionAction { - Handled, - NotHandled, - Selected(usize), -} diff --git a/src/views/welcome_page.rs b/src/views/welcome_page.rs index 091d6ce..2b58199 100644 --- a/src/views/welcome_page.rs +++ b/src/views/welcome_page.rs @@ -38,10 +38,6 @@ impl WelcomeView { Self { selected_index: 0 } } - pub fn selected_index(&self) -> usize { - self.selected_index - } - pub fn handle_key_event(&mut self, key: KeyEvent) -> WelcomeAction { match key.code { KeyCode::Up | KeyCode::Char('k') => { @@ -171,4 +167,4 @@ impl WelcomeView { frame.render_widget(menu_item, *area); } } -} \ No newline at end of file +}