diff --git a/README.md b/README.md index 31b04c2..979173f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ A Terminal User Interface (TUI) for managing Sonarr. +> ✨ **Note**: This project was fully vibe coded with AI assistance, showcasing modern development workflows and comprehensive feature implementation. + ## Features - View system status and health - Browse series and episodes - Monitor download queue - View download history -- Interactive TUI interface +- Interactive TUI interface with vim-like keybind support +- Configurable UI preferences and keybind modes +- In-app configuration editing for Sonarr connection and UI settings - Configurable via config files, environment variables, or CLI arguments ## Installation @@ -39,6 +43,12 @@ Example configuration: [sonarr] url = "http://localhost:8989" api_key = "your-api-key-here" + +[ui] +# Keybind mode: "Normal" or "Vim" +keybind_mode = "Normal" +# Show help text in footer +show_help = true ``` ### Environment Variables @@ -48,6 +58,8 @@ Set these environment variables: ```bash export YARR_SONARR_URL="http://localhost:8989" export YARR_SONARR_API_KEY="your-api-key-here" +export YARR_UI_KEYBIND_MODE="Vim" +export YARR_UI_SHOW_HELP="true" ``` ### Command Line Arguments @@ -134,11 +146,49 @@ yarr completions powershell > yarr.ps1 ## TUI Controls +### Normal Mode (Default) + - `q` - Quit - `↑/↓` or `j/k` - Navigate up/down - `Enter` - Select/expand -- `Tab` - Switch between panels +- `Tab` - Switch between tabs +- `d` - Toggle details - `r` - Refresh data +- `/` - Search (in Search tab) +- `s` - Save configuration changes + +### Vim Mode + +- `q` - Quit +- `h/j/k/l` - Navigate left/down/up/right +- `w/b` - Next/previous tab +- `gg` - Go to first item +- `G` - Go to last item +- `v` - Toggle details (visual mode) +- `u` - Refresh data (undo) +- `/` - Search mode +- `i` - Insert/input mode +- `s` - Save configuration changes + +### Settings Tab + +Use the Settings tab to: +- Toggle between Normal and Vim keybind modes +- Enable/disable help text display +- Edit Sonarr server URL and API key +- Save configuration changes to file + +Access the Settings tab by navigating to the last tab or pressing `Tab` repeatedly. + +### In-App Configuration + +You can configure Sonarr connection settings directly within the application: +1. Navigate to the Settings tab +2. Select "Sonarr URL" or "API Key" and press Enter +3. Type the new value and press Enter to save +4. Press 's' to save all changes to the config file + +This eliminates the need to manually edit config files for basic setup. ## Getting Started diff --git a/src/api.rs b/src/api.rs index 9ac44a6..b4ddced 100644 --- a/src/api.rs +++ b/src/api.rs @@ -172,7 +172,7 @@ impl SonarrClient { } pub async fn search_series(&self, term: &str) -> Result> { - self.get_debug(&format!( + self.get(&format!( "/series/lookup?term={}", urlencoding::encode(term) )) diff --git a/src/config.rs b/src/config.rs index 36c8867..49ec250 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { pub sonarr: SonarrConfig, + pub ui: UiConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -13,6 +14,33 @@ pub struct SonarrConfig { pub api_key: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + pub keybind_mode: KeybindMode, + pub show_help: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum KeybindMode { + Normal, + Vim, +} + +impl Default for KeybindMode { + fn default() -> Self { + Self::Normal + } +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + keybind_mode: KeybindMode::default(), + show_help: true, + } + } +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -20,6 +48,7 @@ impl Default for AppConfig { url: "http://localhost:8989".to_string(), api_key: String::new(), }, + ui: UiConfig::default(), } } } @@ -92,6 +121,10 @@ impl AppConfig { url: "http://localhost:8989".to_string(), api_key: "your-api-key-here".to_string(), }, + ui: UiConfig { + keybind_mode: KeybindMode::Normal, + show_help: true, + }, }; let toml_content = toml::to_string_pretty(&sample_config)?; diff --git a/src/main.rs b/src/main.rs index bac8cf3..0e48694 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box> { } Some(cli::Commands::Tui) | None => { // Default to TUI mode - tui::run_app(client).await?; + tui::run_app(client, config).await?; } Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => { // Already handled above diff --git a/src/tui.rs b/src/tui.rs index 32a1f4e..b8d11f0 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -23,6 +23,7 @@ use tokio::sync::mpsc; use crate::api::{ Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus, }; +use crate::config::{AppConfig, KeybindMode}; #[derive(Debug)] pub enum AppEvent { @@ -47,11 +48,14 @@ pub enum TabIndex { History, Health, Search, + Settings, } impl TabIndex { fn titles() -> Vec<&'static str> { - vec!["Series", "Calendar", "Queue", "History", "Health", "Search"] + vec![ + "Series", "Calendar", "Queue", "History", "Health", "Search", "Settings", + ] } fn from_index(index: usize) -> Self { @@ -62,11 +66,12 @@ impl TabIndex { 3 => TabIndex::History, 4 => TabIndex::Health, 5 => TabIndex::Search, + 6 => TabIndex::Settings, _ => TabIndex::Series, } } - fn to_index(self) -> usize { + fn to_index(&self) -> usize { match self { TabIndex::Series => 0, TabIndex::Calendar => 1, @@ -74,6 +79,7 @@ impl TabIndex { TabIndex::History => 3, TabIndex::Health => 4, TabIndex::Search => 5, + TabIndex::Settings => 6, } } } @@ -87,6 +93,7 @@ pub struct App { pub history_table_state: TableState, pub health_list_state: ListState, pub search_list_state: ListState, + pub settings_list_state: ListState, pub series: Vec, pub episodes: Vec, pub queue: Vec, @@ -102,6 +109,12 @@ pub struct App { 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, } impl Default for App { @@ -115,6 +128,7 @@ impl Default for App { history_table_state: TableState::default(), health_list_state: ListState::default(), search_list_state: ListState::default(), + settings_list_state: ListState::default(), series: Vec::new(), episodes: Vec::new(), queue: Vec::new(), @@ -130,8 +144,15 @@ impl Default for App { 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(), }; app.series_list_state.select(Some(0)); + app.settings_list_state.select(Some(0)); app } } @@ -215,6 +236,9 @@ impl App { self.search_list_state.select(Some(i)); } } + TabIndex::Settings => { + self.next_settings_item(); + } } } @@ -316,6 +340,9 @@ impl App { self.search_list_state.select(Some(i)); } } + TabIndex::Settings => { + self.previous_settings_item(); + } } } @@ -361,9 +388,208 @@ impl App { 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::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::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) -> Result<(), Box> { +pub async fn run_app( + client: SonarrClient, + config: AppConfig, +) -> Result<(), Box> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -373,6 +599,7 @@ pub async fn run_app(client: SonarrClient) -> Result<(), Box, ) { if app.input_mode { - match key.code { - KeyCode::Enter => { - 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))); - } + 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 => { + } + KeyCode::Esc => { + if app.editing_url || app.editing_api_key { + app.cancel_editing(); + } else { app.exit_search_mode(); } - KeyCode::Char(c) => { + } + 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 => { + } + 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(); } - _ => {} } - } else { - 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') => app.next_item(), - KeyCode::Up | KeyCode::Char('k') => app.previous_item(), - KeyCode::Char('d') => app.toggle_details(), - KeyCode::Char('r') => { - app.loading = true; - app.clear_error(); - load_all_data(client, tx); + _ => {} + } +} + +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::Char('/') => { - if app.current_tab == TabIndex::Search { - app.enter_search_mode(); - } - } - KeyCode::Esc => { - app.clear_error(); - if app.search_mode { - app.exit_search_mode(); - } - } - _ => {} } + 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.current_tab == TabIndex::Settings { + app.handle_settings_input(); + } + } + KeyCode::Esc => { + 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(); + } + } + _ => {} } } @@ -673,6 +1044,7 @@ fn ui(f: &mut Frame, app: &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 @@ -1072,11 +1444,140 @@ fn render_search_tab(f: &mut Frame, area: Rect, app: &App) { } } +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.input_mode { - "ESC: Cancel | Enter: Search | Type to enter search term" + 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 { - "q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search (in Search tab)" + 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)]; diff --git a/yarr.toml.example b/yarr.toml.example index 041634d..38e8075 100644 --- a/yarr.toml.example +++ b/yarr.toml.example @@ -6,15 +6,36 @@ [sonarr] # Sonarr server URL (required) # Example: "http://localhost:8989" or "https://sonarr.example.com" +# Can also be edited from the Settings tab in the application url = "http://localhost:8989" # Sonarr API key (required) # You can find this in Sonarr under Settings > General > Security > API Key +# Can also be edited from the Settings tab in the application api_key = "your-api-key-here" +[ui] +# Keybind mode: "Normal" or "Vim" +# Normal mode uses standard navigation keys (arrows, j/k) +# Vim mode uses vim-like keybinds (hjkl, gg, G, etc.) +# Can be toggled from the Settings tab in the application +keybind_mode = "Normal" + +# Show help text in the footer +# Can be toggled from the Settings tab in the application +show_help = true + +# All settings can be changed in the Settings tab: +# 1. Launch yarr and navigate to the Settings tab (last tab) +# 2. Use arrow keys to select a setting +# 3. Press Enter to edit Sonarr URL/API key or toggle other options +# 4. Press 's' to save changes + # Environment variables can also be used: # YARR_SONARR_URL="http://localhost:8989" # YARR_SONARR_API_KEY="your-api-key-here" +# YARR_UI_KEYBIND_MODE="Vim" +# YARR_UI_SHOW_HELP="false" # # Command line arguments take highest priority: # yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"