use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap, }, Frame, Terminal, }; use std::{ io, time::{Duration, Instant}, }; use tokio::sync::mpsc; use crate::config::{AppConfig, KeybindMode}; use yarr_api::{ Episode, HealthResource, HistoryItem, QueueItem, ReleaseResource, Series, SonarrClient, SystemStatus, }; #[derive(Debug)] pub enum AppEvent { Tick, Key(crossterm::event::KeyEvent), Error(String), SeriesLoaded(Vec), #[allow(dead_code)] EpisodesLoaded(Vec), QueueLoaded(Vec), HistoryLoaded(Vec), SystemStatusLoaded(SystemStatus), HealthLoaded(Vec), CalendarLoaded(Vec), SearchResults(Vec), ReleasesLoaded(Vec), ReleaseDownloaded(String), } #[derive(Debug, Clone, Copy, PartialEq)] pub enum TabIndex { Series, Search, Calendar, Queue, History, Health, Settings, } impl TabIndex { fn titles() -> Vec<&'static str> { vec![ "Series", "Search", "Calendar", "Queue", "History", "Health", "Settings", ] } fn from_index(index: usize) -> Self { match index { 0 => TabIndex::Series, 1 => TabIndex::Search, 2 => TabIndex::Calendar, 3 => TabIndex::Queue, 4 => TabIndex::History, 5 => TabIndex::Health, 6 => TabIndex::Settings, _ => TabIndex::Series, } } fn to_index(&self) -> usize { match self { TabIndex::Series => 0, TabIndex::Search => 1, TabIndex::Calendar => 2, TabIndex::Queue => 3, TabIndex::History => 4, TabIndex::Health => 5, TabIndex::Settings => 6, } } } pub struct App { pub should_quit: bool, pub current_tab: TabIndex, pub series_list_state: ListState, pub episodes_list_state: ListState, pub queue_table_state: TableState, pub history_table_state: TableState, pub health_list_state: ListState, pub search_list_state: ListState, pub settings_list_state: ListState, pub releases_list_state: ListState, pub series: Vec, pub episodes: Vec, pub queue: Vec, pub history: Vec, pub health: Vec, pub calendar: Vec, pub system_status: Option, #[allow(dead_code)] pub selected_series: Option, pub loading: bool, pub error_message: Option, pub search_input: String, pub search_results: Vec, pub search_mode: bool, pub show_details: bool, pub input_mode: bool, pub config: AppConfig, pub config_changed: bool, pub editing_url: bool, pub editing_api_key: bool, pub url_input: String, pub api_key_input: String, pub show_releases_popup: bool, pub releases: Vec, pub selected_search_series: Option, pub loading_releases: bool, } impl Default for App { fn default() -> Self { let mut app = Self { should_quit: false, current_tab: TabIndex::Series, series_list_state: ListState::default(), episodes_list_state: ListState::default(), queue_table_state: TableState::default(), history_table_state: TableState::default(), health_list_state: ListState::default(), search_list_state: ListState::default(), settings_list_state: ListState::default(), releases_list_state: ListState::default(), series: Vec::new(), episodes: Vec::new(), queue: Vec::new(), history: Vec::new(), health: Vec::new(), calendar: Vec::new(), system_status: None, selected_series: None, loading: false, error_message: None, search_input: String::new(), search_results: Vec::new(), search_mode: false, show_details: false, input_mode: false, config: AppConfig::default(), config_changed: false, editing_url: false, editing_api_key: false, url_input: String::new(), api_key_input: String::new(), show_releases_popup: false, releases: Vec::new(), selected_search_series: None, loading_releases: false, }; app.series_list_state.select(Some(0)); app.settings_list_state.select(Some(0)); app.releases_list_state.select(Some(0)); app } } impl App { pub fn next_tab(&mut self) { let current_index = self.current_tab.to_index(); let next_index = (current_index + 1) % TabIndex::titles().len(); self.current_tab = TabIndex::from_index(next_index); } pub fn previous_tab(&mut self) { let current_index = self.current_tab.to_index(); let prev_index = if current_index == 0 { TabIndex::titles().len() - 1 } else { current_index - 1 }; self.current_tab = TabIndex::from_index(prev_index); } pub fn next_item(&mut self) { if self.show_releases_popup { self.next_release(); return; } match self.current_tab { TabIndex::Series => { let len = self.series.len(); if len > 0 { let i = match self.series_list_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.series_list_state.select(Some(i)); } } TabIndex::Calendar => { let len = self.calendar.len(); if len > 0 { let i = match self.episodes_list_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.episodes_list_state.select(Some(i)); } } TabIndex::Queue => { let len = self.queue.len(); if len > 0 { let i = match self.queue_table_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.queue_table_state.select(Some(i)); } } TabIndex::History => { let len = self.history.len(); if len > 0 { let i = match self.history_table_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.history_table_state.select(Some(i)); } } TabIndex::Health => { let len = self.health.len(); if len > 0 { let i = match self.health_list_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.health_list_state.select(Some(i)); } } TabIndex::Search => { let len = self.search_results.len(); if len > 0 { let i = match self.search_list_state.selected() { Some(i) => (i + 1) % len, None => 0, }; self.search_list_state.select(Some(i)); } } TabIndex::Settings => { self.next_settings_item(); } } } pub fn previous_item(&mut self) { if self.show_releases_popup { self.previous_release(); return; } match self.current_tab { TabIndex::Series => { let len = self.series.len(); if len > 0 { let i = match self.series_list_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.series_list_state.select(Some(i)); } } TabIndex::Calendar => { let len = self.calendar.len(); if len > 0 { let i = match self.episodes_list_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.episodes_list_state.select(Some(i)); } } TabIndex::Queue => { let len = self.queue.len(); if len > 0 { let i = match self.queue_table_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.queue_table_state.select(Some(i)); } } TabIndex::History => { let len = self.history.len(); if len > 0 { let i = match self.history_table_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.history_table_state.select(Some(i)); } } TabIndex::Health => { let len = self.health.len(); if len > 0 { let i = match self.health_list_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.health_list_state.select(Some(i)); } } TabIndex::Search => { let len = self.search_results.len(); if len > 0 { let i = match self.search_list_state.selected() { Some(i) => { if i == 0 { len - 1 } else { i - 1 } } None => 0, }; self.search_list_state.select(Some(i)); } } TabIndex::Settings => { self.previous_settings_item(); } } } #[allow(dead_code)] pub fn get_selected_series(&self) -> Option<&Series> { if let Some(index) = self.series_list_state.selected() { self.series.get(index) } else { None } } #[allow(dead_code)] pub fn get_selected_search_result(&self) -> Option<&Series> { if let Some(index) = self.search_list_state.selected() { self.search_results.get(index) } else { None } } pub fn get_selected_release(&self) -> Option<&ReleaseResource> { if let Some(index) = self.releases_list_state.selected() { self.releases.get(index) } else { None } } pub fn show_releases_popup(&mut self, series: Series) { self.show_releases_popup = true; self.selected_search_series = Some(series); self.releases.clear(); self.releases_list_state.select(Some(0)); self.loading_releases = true; } pub fn hide_releases_popup(&mut self) { self.show_releases_popup = false; self.selected_search_series = None; self.releases.clear(); self.loading_releases = false; } pub fn next_release(&mut self) { if !self.releases.is_empty() { let i = match self.releases_list_state.selected() { Some(i) => (i + 1) % self.releases.len(), None => 0, }; self.releases_list_state.select(Some(i)); } } pub fn previous_release(&mut self) { if !self.releases.is_empty() { let i = match self.releases_list_state.selected() { Some(i) => { if i == 0 { self.releases.len() - 1 } else { i - 1 } } None => 0, }; self.releases_list_state.select(Some(i)); } } pub fn enter_search_mode(&mut self) { self.search_mode = true; self.input_mode = true; self.search_input.clear(); self.search_results.clear(); self.search_list_state.select(None); } pub fn exit_search_mode(&mut self) { self.search_mode = false; self.input_mode = false; self.search_input.clear(); self.search_results.clear(); } pub fn toggle_details(&mut self) { self.show_details = !self.show_details; } pub fn clear_error(&mut self) { self.error_message = None; } pub fn set_error(&mut self, error: String) { self.error_message = Some(error); } pub fn go_to_first(&mut self) { match self.current_tab { TabIndex::Series => { if !self.series.is_empty() { self.series_list_state.select(Some(0)); } } TabIndex::Calendar => { if !self.calendar.is_empty() { self.episodes_list_state.select(Some(0)); } } TabIndex::Queue => { if !self.queue.is_empty() { self.queue_table_state.select(Some(0)); } } TabIndex::History => { if !self.history.is_empty() { self.history_table_state.select(Some(0)); } } TabIndex::Health => { if !self.health.is_empty() { self.health_list_state.select(Some(0)); } } TabIndex::Search => { if !self.search_results.is_empty() { self.search_list_state.select(Some(0)); } } TabIndex::Settings => { self.settings_list_state.select(Some(0)); } } } pub fn go_to_last(&mut self) { match self.current_tab { TabIndex::Series => { if !self.series.is_empty() { self.series_list_state.select(Some(self.series.len() - 1)); } } TabIndex::Calendar => { if !self.calendar.is_empty() { self.episodes_list_state .select(Some(self.calendar.len() - 1)); } } TabIndex::Queue => { if !self.queue.is_empty() { self.queue_table_state.select(Some(self.queue.len() - 1)); } } TabIndex::History => { if !self.history.is_empty() { self.history_table_state .select(Some(self.history.len() - 1)); } } TabIndex::Health => { if !self.health.is_empty() { self.health_list_state.select(Some(self.health.len() - 1)); } } TabIndex::Search => { if !self.search_results.is_empty() { self.search_list_state .select(Some(self.search_results.len() - 1)); } } TabIndex::Settings => { self.settings_list_state.select(Some(4)); // Total settings options - 1 } } } pub fn toggle_keybind_mode(&mut self) { self.config.ui.keybind_mode = match self.config.ui.keybind_mode { KeybindMode::Normal => KeybindMode::Vim, KeybindMode::Vim => KeybindMode::Normal, }; self.config_changed = true; } pub fn toggle_help(&mut self) { self.config.ui.show_help = !self.config.ui.show_help; self.config_changed = true; } pub fn handle_settings_input(&mut self) { if let Some(selected) = self.settings_list_state.selected() { match selected { 0 => self.toggle_keybind_mode(), 1 => self.toggle_help(), 2 => self.start_editing_url(), 3 => self.start_editing_api_key(), _ => {} } } } pub fn start_editing_url(&mut self) { self.editing_url = true; self.input_mode = true; self.url_input = self.config.sonarr.url.clone(); } pub fn start_editing_api_key(&mut self) { self.editing_api_key = true; self.input_mode = true; self.api_key_input = self.config.sonarr.api_key.clone(); } pub fn finish_editing_url(&mut self) { let trimmed_url = self.url_input.trim(); if !trimmed_url.is_empty() { // Basic URL validation if trimmed_url.starts_with("http://") || trimmed_url.starts_with("https://") { self.config.sonarr.url = trimmed_url.to_string(); self.config_changed = true; } else { self.set_error("URL must start with http:// or https://".to_string()); } } self.editing_url = false; self.input_mode = false; self.url_input.clear(); } pub fn finish_editing_api_key(&mut self) { if !self.api_key_input.trim().is_empty() { self.config.sonarr.api_key = self.api_key_input.trim().to_string(); self.config_changed = true; } self.editing_api_key = false; self.input_mode = false; self.api_key_input.clear(); } pub fn cancel_editing(&mut self) { self.editing_url = false; self.editing_api_key = false; self.input_mode = false; self.url_input.clear(); self.api_key_input.clear(); } pub fn next_settings_item(&mut self) { let i = match self.settings_list_state.selected() { Some(i) => { if i >= 4 { 0 } else { i + 1 } } None => 0, }; self.settings_list_state.select(Some(i)); } pub fn previous_settings_item(&mut self) { let i = match self.settings_list_state.selected() { Some(i) => { if i == 0 { 4 } else { i - 1 } } None => 0, }; self.settings_list_state.select(Some(i)); } pub fn save_config(&mut self) -> Result<(), Box> { if !self.config_changed { return Ok(()); } // Find a writable config path let config_paths = crate::config::AppConfig::get_default_config_paths(); let config_path = if let Some(path) = config_paths.get(1) { // Prefer user config directory path.clone() } else { std::env::current_dir()?.join("yarr.toml") }; // Create directory if it doesn't exist if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent)?; } // Save the config let toml_content = toml::to_string_pretty(&self.config)?; std::fs::write(&config_path, toml_content)?; self.config_changed = false; Ok(()) } } pub async fn run_app( client: SonarrClient, config: AppConfig, ) -> Result<(), Box> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Create app and run it let mut app = App::default(); app.config = config; let res = run_tui(&mut terminal, &mut app, client).await; // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; res } async fn run_tui( terminal: &mut Terminal, app: &mut App, client: SonarrClient, ) -> Result<(), Box> { let (tx, mut rx) = mpsc::unbounded_channel(); // Spawn task to handle keyboard input let tx_input = tx.clone(); tokio::spawn(async move { let mut last_tick = Instant::now(); let tick_rate = Duration::from_millis(250); loop { let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if event::poll(timeout).unwrap() { if let Event::Key(key) = event::read().unwrap() { if key.kind == KeyEventKind::Press { if tx_input.send(AppEvent::Key(key)).is_err() { return; } } } } if last_tick.elapsed() >= tick_rate { if tx_input.send(AppEvent::Tick).is_err() { return; } last_tick = Instant::now(); } } }); // Load initial data load_all_data(client.clone(), tx.clone()).await; // Main event loop while !app.should_quit { terminal.draw(|f| ui(f, app))?; if let Some(event) = rx.recv().await { match event { AppEvent::Key(key) => { handle_input(app, key, client.clone(), tx.clone()).await; } AppEvent::Tick => { // Handle periodic updates if needed } AppEvent::Error(err) => { app.set_error(err); app.loading = false; } AppEvent::SeriesLoaded(series) => { app.series = series; app.loading = false; if !app.series.is_empty() && app.series_list_state.selected().is_none() { app.series_list_state.select(Some(0)); } } AppEvent::EpisodesLoaded(episodes) => { app.episodes = episodes; app.loading = false; } AppEvent::QueueLoaded(queue) => { app.queue = queue; app.loading = false; } AppEvent::HistoryLoaded(history) => { app.history = history; app.loading = false; } AppEvent::SystemStatusLoaded(status) => { app.system_status = Some(status); app.loading = false; } AppEvent::HealthLoaded(health) => { app.health = health; app.loading = false; } AppEvent::CalendarLoaded(calendar) => { app.calendar = calendar; app.loading = false; } AppEvent::SearchResults(results) => { app.search_results = results; app.loading = false; if !app.search_results.is_empty() { app.search_list_state.select(Some(0)); } } AppEvent::ReleasesLoaded(releases) => { app.releases = releases; app.loading_releases = false; if !app.releases.is_empty() { app.releases_list_state.select(Some(0)); } } AppEvent::ReleaseDownloaded(title) => { app.hide_releases_popup(); app.set_error(format!("Successfully started download: {}", title)); app.loading = false; } } } } Ok(()) } async fn load_all_data(client: SonarrClient, tx: mpsc::UnboundedSender) { // Load system status let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.get_system_status().await { Ok(status) => { let _ = tx_clone.send(AppEvent::SystemStatusLoaded(status)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!( "Failed to load system status: {}", e ))); } } }); // Load series let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.get_series().await { Ok(series) => { let _ = tx_clone.send(AppEvent::SeriesLoaded(series)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Failed to load series: {}", e))); } } }); // Load queue let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.get_queue().await { Ok(queue_data) => { let _ = tx_clone.send(AppEvent::QueueLoaded(queue_data.records)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Failed to load queue: {}", e))); } } }); // Load history let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.get_history().await { Ok(history_data) => { let _ = tx_clone.send(AppEvent::HistoryLoaded(history_data.records)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Failed to load history: {}", e))); } } }); // Load health let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.get_health().await { Ok(health) => { let _ = tx_clone.send(AppEvent::HealthLoaded(health)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Failed to load health: {}", e))); } } }); // Load calendar (upcoming episodes) let tx_clone = tx; tokio::spawn(async move { let start = chrono::Utc::now().format("%Y-%m-%d").to_string(); let end = (chrono::Utc::now() + chrono::Duration::days(7)) .format("%Y-%m-%d") .to_string(); match client.get_calendar(Some(&start), Some(&end)).await { Ok(calendar) => { let _ = tx_clone.send(AppEvent::CalendarLoaded(calendar)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Failed to load calendar: {}", e))); } } }); } async fn handle_input( app: &mut App, key: crossterm::event::KeyEvent, client: SonarrClient, tx: mpsc::UnboundedSender, ) { if app.input_mode { handle_input_mode(app, key, client, tx).await; } else { match app.config.ui.keybind_mode { KeybindMode::Normal => handle_normal_mode(app, key, client, tx).await, KeybindMode::Vim => handle_vim_mode(app, key, client, tx).await, } } } async fn handle_input_mode( app: &mut App, key: crossterm::event::KeyEvent, client: SonarrClient, tx: mpsc::UnboundedSender, ) { match key.code { KeyCode::Enter => { if app.editing_url { app.finish_editing_url(); } else if app.editing_api_key { app.finish_editing_api_key(); } else if !app.search_input.is_empty() { app.loading = true; let search_term = app.search_input.clone(); let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.search_series(&search_term).await { Ok(results) => { let _ = tx_clone.send(AppEvent::SearchResults(results)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e))); } } }); app.input_mode = false; } } KeyCode::Esc => { if app.editing_url || app.editing_api_key { app.cancel_editing(); } else { app.exit_search_mode(); } } KeyCode::Char(c) => { if app.editing_url { app.url_input.push(c); } else if app.editing_api_key { app.api_key_input.push(c); } else { app.search_input.push(c); } } KeyCode::Backspace => { if app.editing_url { app.url_input.pop(); } else if app.editing_api_key { app.api_key_input.pop(); } else { app.search_input.pop(); } } _ => {} } } async fn handle_normal_mode( app: &mut App, key: crossterm::event::KeyEvent, client: SonarrClient, tx: mpsc::UnboundedSender, ) { match key.code { KeyCode::Char('q') => app.should_quit = true, KeyCode::Tab => app.next_tab(), KeyCode::BackTab => app.previous_tab(), KeyCode::Down | KeyCode::Char('j') => { if app.current_tab == TabIndex::Settings { app.next_settings_item(); } else { app.next_item(); } } KeyCode::Up | KeyCode::Char('k') => { if app.current_tab == TabIndex::Settings { app.previous_settings_item(); } else { app.previous_item(); } } KeyCode::Char('d') => app.toggle_details(), KeyCode::Char('r') => { app.loading = true; app.clear_error(); tokio::spawn(async move { load_all_data(client, tx).await; }); } KeyCode::Char('/') => { if app.current_tab == TabIndex::Search { app.enter_search_mode(); } } KeyCode::Char('s') => { if let Err(e) = app.save_config() { app.set_error(format!("Failed to save config: {}", e)); } } KeyCode::Enter => { if app.show_releases_popup { // Download the selected release if let Some(release) = app.get_selected_release().cloned() { let client_clone = client.clone(); let tx_clone = tx.clone(); tokio::spawn(async move { match client_clone.download_release(&release).await { Ok(()) => { let title = release.title.unwrap_or_default(); let _ = tx_clone.send(AppEvent::ReleaseDownloaded(title)); } Err(e) => { let _ = tx_clone .send(AppEvent::Error(format!("Download failed: {}", e))); } } }); app.hide_releases_popup(); } } else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() { // Show releases popup for selected search result if let Some(selected_series) = app.get_selected_search_result().cloned() { app.show_releases_popup(selected_series.clone()); let client_clone = client.clone(); let tx_clone = tx.clone(); let series_id = selected_series.id.unwrap_or(0); tokio::spawn(async move { match client_clone .search_releases(Some(series_id), None, None) .await { Ok(releases) => { let _ = tx_clone.send(AppEvent::ReleasesLoaded(releases)); } Err(e) => { let _ = tx_clone.send(AppEvent::Error(format!( "Failed to load releases: {}", e ))); } } }); } } else if app.current_tab == TabIndex::Settings { app.handle_settings_input(); } } KeyCode::Esc => { if app.show_releases_popup { app.hide_releases_popup(); } else { app.clear_error(); if app.search_mode { app.exit_search_mode(); } } } _ => {} } } async fn handle_vim_mode( app: &mut App, key: crossterm::event::KeyEvent, client: SonarrClient, tx: mpsc::UnboundedSender, ) { match key.code { // Vim-like quit KeyCode::Char('q') => app.should_quit = true, // Tab navigation KeyCode::Char('w') | KeyCode::Tab => app.next_tab(), KeyCode::Char('b') | KeyCode::BackTab => app.previous_tab(), // Vim navigation KeyCode::Char('j') | KeyCode::Down => { if app.current_tab == TabIndex::Settings { app.next_settings_item(); } else { app.next_item(); } } KeyCode::Char('k') | KeyCode::Up => { if app.current_tab == TabIndex::Settings { app.previous_settings_item(); } else { app.previous_item(); } } KeyCode::Char('h') | KeyCode::Left => app.previous_tab(), KeyCode::Char('l') | KeyCode::Right => app.next_tab(), // Go to first/last KeyCode::Char('g') => { // Handle gg for go to first (simplified - in real vim this would need state) app.go_to_first(); } KeyCode::Char('G') => app.go_to_last(), // Other vim-like commands KeyCode::Char('v') => app.toggle_details(), // visual/details mode KeyCode::Char('u') => { // undo/refresh app.loading = true; app.clear_error(); tokio::spawn(async move { load_all_data(client, tx).await; }); } KeyCode::Char('/') => { if app.current_tab == TabIndex::Search { app.enter_search_mode(); } } KeyCode::Char('i') => { // insert mode - enter search if on search tab if app.current_tab == TabIndex::Search { app.enter_search_mode(); } } KeyCode::Enter => { if app.current_tab == TabIndex::Settings { app.handle_settings_input(); } } KeyCode::Char('s') => { if let Err(e) = app.save_config() { app.set_error(format!("Failed to save config: {}", e)); } } KeyCode::Esc => { app.clear_error(); if app.search_mode { app.exit_search_mode(); } } _ => {} } } fn ui(f: &mut Frame, app: &App) { let size = f.area(); // Create main layout let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Header Constraint::Min(0), // Main content Constraint::Length(3), // Footer ]) .split(size); // Render header render_header(f, chunks[0], app); // Render main content based on current tab match app.current_tab { TabIndex::Series => render_series_tab(f, chunks[1], app), TabIndex::Calendar => render_calendar_tab(f, chunks[1], app), TabIndex::Queue => render_queue_tab(f, chunks[1], app), TabIndex::History => render_history_tab(f, chunks[1], app), TabIndex::Health => render_health_tab(f, chunks[1], app), TabIndex::Search => render_search_tab(f, chunks[1], app), TabIndex::Settings => render_settings_tab(f, chunks[1], app), } // Render footer render_footer(f, chunks[2], app); // Render releases popup if it should be shown if app.show_releases_popup { render_releases_popup(f, app); } // Render error popup if there's an error if app.error_message.is_some() { render_error_popup(f, size, app); } } fn render_header(f: &mut Frame, area: Rect, app: &App) { let tabs = Tabs::new(TabIndex::titles()) .block( Block::default() .borders(Borders::ALL) .title("Yarr - Sonarr TUI"), ) .style(Style::default().fg(Color::White)) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) .select(app.current_tab.to_index()); f.render_widget(tabs, area); } fn render_series_tab(f: &mut Frame, area: Rect, app: &App) { if app.series.is_empty() { let loading_text = if app.loading { "Loading series..." } else { "No series found" }; let paragraph = Paragraph::new(loading_text) .block(Block::default().borders(Borders::ALL).title("Series")) .alignment(Alignment::Center); f.render_widget(paragraph, area); return; } let items: Vec = app .series .iter() .map(|s| { let title = s.title.as_deref().unwrap_or("Unknown"); let status = &s.status; let monitored = if s.monitored { "✓" } else { "✗" }; let stats = s .statistics .as_ref() .map(|stats| format!(" ({}/{})", stats.episode_file_count, stats.episode_count)) .unwrap_or_default(); ListItem::new(format!("{} {} [{}]{}", monitored, title, status, stats)) }) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Series")) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, area, &mut app.series_list_state.clone()); } fn render_calendar_tab(f: &mut Frame, area: Rect, app: &App) { if app.calendar.is_empty() { let loading_text = if app.loading { "Loading calendar..." } else { "No upcoming episodes" }; let paragraph = Paragraph::new(loading_text) .block( Block::default() .borders(Borders::ALL) .title("Upcoming Episodes"), ) .alignment(Alignment::Center); f.render_widget(paragraph, area); return; } let items: Vec = app .calendar .iter() .map(|e| { let title = e.title.as_deref().unwrap_or("Unknown Episode"); let series_title = e .series .as_ref() .and_then(|s| s.title.as_deref()) .unwrap_or("Unknown Series"); let air_date = e .air_date_utc .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .or_else(|| e.air_date.clone()) .unwrap_or_default(); ListItem::new(format!( "S{:02}E{:02} {} - {} [{}]", e.season_number, e.episode_number, series_title, title, air_date )) }) .collect(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Upcoming Episodes (Next 7 Days)"), ) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, area, &mut app.episodes_list_state.clone()); } fn render_queue_tab(f: &mut Frame, area: Rect, app: &App) { if app.queue.is_empty() { let loading_text = if app.loading { "Loading queue..." } else { "Queue is empty" }; let paragraph = Paragraph::new(loading_text) .block( Block::default() .borders(Borders::ALL) .title("Download Queue"), ) .alignment(Alignment::Center); f.render_widget(paragraph, area); return; } let header = Row::new(vec!["Title", "Status", "Progress", "Quality", "Size"]); let rows: Vec = app .queue .iter() .map(|q| { let title = q.title.as_deref().unwrap_or("Unknown"); let status = &q.status; let quality = q .quality .as_ref() .and_then(|qual| qual.quality.name.as_deref()) .unwrap_or("Unknown"); let size = format!("{:.1} GB", q.size / 1_000_000_000.0); // Simple progress calculation (this would need more sophisticated logic in real implementation) let progress = "50%"; // Placeholder Row::new(vec![ Cell::from(title), Cell::from(status.as_str()), Cell::from(progress), Cell::from(quality), Cell::from(size), ]) }) .collect(); let table = Table::new( rows, &[ Constraint::Percentage(40), Constraint::Percentage(15), Constraint::Percentage(15), Constraint::Percentage(15), Constraint::Percentage(15), ], ) .header(header.style(Style::default().fg(Color::Yellow))) .block( Block::default() .borders(Borders::ALL) .title("Download Queue"), ) .row_highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(table, area, &mut app.queue_table_state.clone()); } fn render_history_tab(f: &mut Frame, area: Rect, app: &App) { if app.history.is_empty() { let loading_text = if app.loading { "Loading history..." } else { "No history found" }; let paragraph = Paragraph::new(loading_text) .block(Block::default().borders(Borders::ALL).title("History")) .alignment(Alignment::Center); f.render_widget(paragraph, area); return; } let header = Row::new(vec!["Date", "Series", "Episode", "Event", "Quality"]); let rows: Vec = app .history .iter() .map(|h| { let date = h.date.format("%Y-%m-%d %H:%M").to_string(); let series = h .series .as_ref() .and_then(|s| s.title.as_deref()) .unwrap_or("Unknown"); let episode = h .episode .as_ref() .map(|e| format!("S{:02}E{:02}", e.season_number, e.episode_number)) .unwrap_or_default(); let event = h.event_type.as_deref().unwrap_or("Unknown"); let quality = h .quality .as_ref() .and_then(|q| q.quality.name.as_deref()) .unwrap_or("Unknown"); Row::new(vec![ Cell::from(date), Cell::from(series), Cell::from(episode), Cell::from(event), Cell::from(quality), ]) }) .collect(); let table = Table::new( rows, &[ Constraint::Percentage(20), Constraint::Percentage(30), Constraint::Percentage(15), Constraint::Percentage(20), Constraint::Percentage(15), ], ) .header(header.style(Style::default().fg(Color::Yellow))) .block(Block::default().borders(Borders::ALL).title("History")) .row_highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(table, area, &mut app.history_table_state.clone()); } fn render_health_tab(f: &mut Frame, area: Rect, app: &App) { if app.health.is_empty() { let loading_text = if app.loading { "Loading health status..." } else { "All systems healthy" }; let paragraph = Paragraph::new(loading_text) .block( Block::default() .borders(Borders::ALL) .title("Health Status"), ) .alignment(Alignment::Center) .style(Style::default().fg(Color::Green)); f.render_widget(paragraph, area); return; } let items: Vec = app .health .iter() .map(|h| { let source = h.source.as_deref().unwrap_or("System"); let message = h.message.as_deref().unwrap_or("No message"); let health_type = &h.health_type; let color = match health_type.as_str() { "error" => Color::Red, "warning" => Color::Yellow, "notice" => Color::Blue, _ => Color::Green, }; ListItem::new(format!( "[{}] {}: {}", health_type.to_uppercase(), source, message )) .style(Style::default().fg(color)) }) .collect(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Health Status"), ) .highlight_style(Style::default().add_modifier(Modifier::BOLD)); f.render_stateful_widget(list, area, &mut app.health_list_state.clone()); } fn render_search_tab(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); // Search input let input_style = if app.input_mode { Style::default().fg(Color::Yellow) } else { Style::default() }; let input = Paragraph::new(app.search_input.as_str()) .style(input_style) .block( Block::default() .borders(Borders::ALL) .title("Search Series (Press '/' to search, Enter to execute, Enter on result to view releases)"), ); f.render_widget(input, chunks[0]); // Search results if app.search_results.is_empty() { let text = if app.loading { "Searching..." } else if app.search_input.is_empty() { "Enter a search term and press Enter to search for series" } else { "No results found - try a different search term" }; let paragraph = Paragraph::new(text) .block( Block::default() .borders(Borders::ALL) .title("Search Results"), ) .alignment(Alignment::Center); f.render_widget(paragraph, chunks[1]); } else { let items: Vec = app .search_results .iter() .map(|s| { let title = s.title.as_deref().unwrap_or("Unknown"); let year = s.year; let network = s.network.as_deref().unwrap_or("Unknown Network"); let overview = s.overview.as_deref().unwrap_or(""); let truncated_overview = if overview.len() > 80 { format!("{}...", &overview[..77]) } else { overview.to_string() }; ListItem::new(format!( "{} ({}) - {} | {}", title, year, network, truncated_overview )) }) .collect(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Search Results (Press Enter on a series to view available releases)"), ) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, chunks[1], &mut app.search_list_state.clone()); } } fn render_settings_tab(f: &mut Frame, area: Rect, app: &App) { if app.editing_url || app.editing_api_key { render_settings_input(f, area, app); } else { render_settings_list(f, area, app); } } fn render_settings_list(f: &mut Frame, area: Rect, app: &App) { let mut settings_items = vec![ ListItem::new(format!( "Keybind Mode: {} (Press Enter to toggle)", match app.config.ui.keybind_mode { KeybindMode::Normal => "Normal", KeybindMode::Vim => "Vim", } )), ListItem::new(format!( "Show Help: {} (Press Enter to toggle)", if app.config.ui.show_help { "Yes" } else { "No" } )), ListItem::new(format!( "Sonarr URL: {} (Press Enter to edit)", if app.config.sonarr.url.is_empty() { "Not set" } else { &app.config.sonarr.url } )), ListItem::new(format!( "API Key: {} (Press Enter to edit)", if app.config.sonarr.api_key.is_empty() { "Not set" } else { "***hidden***" } )), ListItem::new(if app.config_changed { "Settings changed - Press 's' to save" } else { "Press 's' to save settings" }), ]; // Add keybind help based on current mode settings_items.push(ListItem::new("")); settings_items.push(ListItem::new("Keybind Modes:")); settings_items.push(ListItem::new( " Normal: Standard navigation (arrows, j/k, tab)", )); settings_items.push(ListItem::new( " Vim: Vim-like navigation (hjkl, gg/G, w/b)", )); let title = if app.config_changed { "Settings (Unsaved Changes)" } else { "Settings" }; let list = List::new(settings_items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, area, &mut app.settings_list_state.clone()); } fn render_settings_input(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Input field Constraint::Min(0), // Instructions ]) .split(area); let (title, current_input, field_name) = if app.editing_url { ("Edit Sonarr URL", &app.url_input, "URL") } else if app.editing_api_key { ("Edit API Key", &app.api_key_input, "API Key") } else { ("Settings", &String::new(), "") }; // Input field let input = Paragraph::new(current_input.as_str()) .style(Style::default().fg(Color::Yellow)) .block( Block::default() .borders(Borders::ALL) .title(format!("{} - {}", title, field_name)), ); f.render_widget(input, chunks[0]); // Instructions let instructions = vec![ Line::from("Enter: Save and return"), Line::from("Esc: Cancel and return"), Line::from(""), if app.editing_url { Line::from("Enter the Sonarr server URL (must start with http:// or https://)") } else { Line::from("Enter your Sonarr API key (found in Sonarr Settings > General > Security)") }, ]; let help = Paragraph::new(instructions) .block(Block::default().borders(Borders::ALL).title("Instructions")) .wrap(Wrap { trim: true }); f.render_widget(help, chunks[1]); } fn render_footer(f: &mut Frame, area: Rect, app: &App) { let help_text = if app.show_releases_popup { "Enter: Download | Esc: Close | ↑↓/jk: Navigate" } else if app.input_mode { if app.editing_url || app.editing_api_key { "ESC: Cancel | Enter: Save | Type to enter value" } else { "ESC: Cancel | Enter: Search | Type to enter search term" } } else if !app.config.ui.show_help { "" // Don't show help if disabled } else if app.current_tab == TabIndex::Search && !app.search_results.is_empty() { "Enter: Show Releases | ↑↓/jk: Navigate | /: Search | Other: Normal keys" } else { match app.config.ui.keybind_mode { KeybindMode::Normal => { "q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search" } KeybindMode::Vim => { "q: Quit | w/b: Tabs | hjkl: Navigate | v: Details | u: Refresh | /: Search | gg/G: First/Last" } } }; let mut spans = vec![Span::raw(help_text)]; if let Some(status) = &app.system_status { spans.push(Span::raw(" | ")); spans.push(Span::styled( format!("Sonarr v{}", status.version.as_deref().unwrap_or("Unknown")), Style::default().fg(Color::Green), )); } if app.loading { spans.push(Span::raw(" | ")); spans.push(Span::styled( "Loading...", Style::default().fg(Color::Yellow), )); } let paragraph = Paragraph::new(Line::from(spans)) .block(Block::default().borders(Borders::ALL)) .alignment(Alignment::Center); f.render_widget(paragraph, area); } fn render_error_popup(f: &mut Frame, area: Rect, app: &App) { if let Some(error) = &app.error_message { let popup_area = centered_rect(60, 20, area); f.render_widget(Clear, popup_area); let error_paragraph = Paragraph::new(error.as_str()) .block( Block::default() .borders(Borders::ALL) .title("Error") .style(Style::default().fg(Color::Red)), ) .wrap(Wrap { trim: true }) .alignment(Alignment::Center); f.render_widget(error_paragraph, popup_area); } } fn render_releases_popup(f: &mut Frame, app: &App) { let area = centered_rect(80, 70, f.area()); f.render_widget(Clear, area); let title = if let Some(ref series) = app.selected_search_series { format!( "Releases for: {}", series.title.as_deref().unwrap_or("Unknown") ) } else { "Releases".to_string() }; let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)); if app.loading_releases { let loading_text = Paragraph::new("Loading releases...") .block(block) .alignment(Alignment::Center); f.render_widget(loading_text, area); } else if app.releases.is_empty() { let no_releases_text = Paragraph::new("No releases found") .block(block) .alignment(Alignment::Center); f.render_widget(no_releases_text, area); } else { let inner_area = block.inner(area); f.render_widget(block, area); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)]) .split(inner_area); let items: Vec = app .releases .iter() .map(|release| { let title = release.title.as_deref().unwrap_or("Unknown"); let size = format_size(release.size.unwrap_or(0)); let quality = release.quality_weight.unwrap_or(0); let indexer = release.indexer.as_deref().unwrap_or("Unknown"); let seeds = release.seeders.unwrap_or(0); let peers = release.leechers.unwrap_or(0); let status = if release.download_allowed.unwrap_or(false) { if release.rejected.unwrap_or(false) { "❌ Rejected" } else { "✅ Available" } } else { "⛔ Not Allowed" }; ListItem::new(format!( "{} | {} | Q:{} | {} | S:{} P:{} | {}", status, size, quality, indexer, seeds, peers, title )) }) .collect(); let list = List::new(items).highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, chunks[0], &mut app.releases_list_state.clone()); let help_text = Paragraph::new("Enter: Download | Esc: Close | ↑↓: Navigate") .style(Style::default().fg(Color::Gray)) .alignment(Alignment::Center) .block(Block::default().borders(Borders::TOP)); f.render_widget(help_text, chunks[1]); } } fn format_size(bytes: i64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; if bytes == 0 { return "0 B".to_string(); } let mut size = bytes as f64; let mut unit_index = 0; while size >= 1024.0 && unit_index < UNITS.len() - 1 { size /= 1024.0; unit_index += 1; } format!("{:.1} {}", size, UNITS[unit_index]) } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(popup_layout[1])[1] }