feat(tui): add vim-like keybinds and settings tab for config edit
This commit is contained in:
54
README.md
54
README.md
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
A Terminal User Interface (TUI) for managing Sonarr.
|
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
|
## Features
|
||||||
|
|
||||||
- View system status and health
|
- View system status and health
|
||||||
- Browse series and episodes
|
- Browse series and episodes
|
||||||
- Monitor download queue
|
- Monitor download queue
|
||||||
- View download history
|
- 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
|
- Configurable via config files, environment variables, or CLI arguments
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -39,6 +43,12 @@ Example configuration:
|
|||||||
[sonarr]
|
[sonarr]
|
||||||
url = "http://localhost:8989"
|
url = "http://localhost:8989"
|
||||||
api_key = "your-api-key-here"
|
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
|
### Environment Variables
|
||||||
@@ -48,6 +58,8 @@ Set these environment variables:
|
|||||||
```bash
|
```bash
|
||||||
export YARR_SONARR_URL="http://localhost:8989"
|
export YARR_SONARR_URL="http://localhost:8989"
|
||||||
export YARR_SONARR_API_KEY="your-api-key-here"
|
export YARR_SONARR_API_KEY="your-api-key-here"
|
||||||
|
export YARR_UI_KEYBIND_MODE="Vim"
|
||||||
|
export YARR_UI_SHOW_HELP="true"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command Line Arguments
|
### Command Line Arguments
|
||||||
@@ -134,11 +146,49 @@ yarr completions powershell > yarr.ps1
|
|||||||
|
|
||||||
## TUI Controls
|
## TUI Controls
|
||||||
|
|
||||||
|
### Normal Mode (Default)
|
||||||
|
|
||||||
- `q` - Quit
|
- `q` - Quit
|
||||||
- `↑/↓` or `j/k` - Navigate up/down
|
- `↑/↓` or `j/k` - Navigate up/down
|
||||||
- `Enter` - Select/expand
|
- `Enter` - Select/expand
|
||||||
- `Tab` - Switch between panels
|
- `Tab` - Switch between tabs
|
||||||
|
- `d` - Toggle details
|
||||||
- `r` - Refresh data
|
- `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
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ impl SonarrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>> {
|
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>> {
|
||||||
self.get_debug(&format!(
|
self.get(&format!(
|
||||||
"/series/lookup?term={}",
|
"/series/lookup?term={}",
|
||||||
urlencoding::encode(term)
|
urlencoding::encode(term)
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub sonarr: SonarrConfig,
|
pub sonarr: SonarrConfig,
|
||||||
|
pub ui: UiConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -13,6 +14,33 @@ pub struct SonarrConfig {
|
|||||||
pub api_key: String,
|
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 {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -20,6 +48,7 @@ impl Default for AppConfig {
|
|||||||
url: "http://localhost:8989".to_string(),
|
url: "http://localhost:8989".to_string(),
|
||||||
api_key: String::new(),
|
api_key: String::new(),
|
||||||
},
|
},
|
||||||
|
ui: UiConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +121,10 @@ impl AppConfig {
|
|||||||
url: "http://localhost:8989".to_string(),
|
url: "http://localhost:8989".to_string(),
|
||||||
api_key: "your-api-key-here".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)?;
|
let toml_content = toml::to_string_pretty(&sample_config)?;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
Some(cli::Commands::Tui) | None => {
|
Some(cli::Commands::Tui) | None => {
|
||||||
// Default to TUI mode
|
// Default to TUI mode
|
||||||
tui::run_app(client).await?;
|
tui::run_app(client, config).await?;
|
||||||
}
|
}
|
||||||
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
|
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
|
||||||
// Already handled above
|
// Already handled above
|
||||||
|
|||||||
527
src/tui.rs
527
src/tui.rs
@@ -23,6 +23,7 @@ use tokio::sync::mpsc;
|
|||||||
use crate::api::{
|
use crate::api::{
|
||||||
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
|
Episode, HealthResource, HistoryItem, QueueItem, Series, SonarrClient, SystemStatus,
|
||||||
};
|
};
|
||||||
|
use crate::config::{AppConfig, KeybindMode};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
@@ -47,11 +48,14 @@ pub enum TabIndex {
|
|||||||
History,
|
History,
|
||||||
Health,
|
Health,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TabIndex {
|
impl TabIndex {
|
||||||
fn titles() -> Vec<&'static str> {
|
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 {
|
fn from_index(index: usize) -> Self {
|
||||||
@@ -62,11 +66,12 @@ impl TabIndex {
|
|||||||
3 => TabIndex::History,
|
3 => TabIndex::History,
|
||||||
4 => TabIndex::Health,
|
4 => TabIndex::Health,
|
||||||
5 => TabIndex::Search,
|
5 => TabIndex::Search,
|
||||||
|
6 => TabIndex::Settings,
|
||||||
_ => TabIndex::Series,
|
_ => TabIndex::Series,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_index(self) -> usize {
|
fn to_index(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
TabIndex::Series => 0,
|
TabIndex::Series => 0,
|
||||||
TabIndex::Calendar => 1,
|
TabIndex::Calendar => 1,
|
||||||
@@ -74,6 +79,7 @@ impl TabIndex {
|
|||||||
TabIndex::History => 3,
|
TabIndex::History => 3,
|
||||||
TabIndex::Health => 4,
|
TabIndex::Health => 4,
|
||||||
TabIndex::Search => 5,
|
TabIndex::Search => 5,
|
||||||
|
TabIndex::Settings => 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,7 @@ pub struct App {
|
|||||||
pub history_table_state: TableState,
|
pub history_table_state: TableState,
|
||||||
pub health_list_state: ListState,
|
pub health_list_state: ListState,
|
||||||
pub search_list_state: ListState,
|
pub search_list_state: ListState,
|
||||||
|
pub settings_list_state: ListState,
|
||||||
pub series: Vec<Series>,
|
pub series: Vec<Series>,
|
||||||
pub episodes: Vec<Episode>,
|
pub episodes: Vec<Episode>,
|
||||||
pub queue: Vec<QueueItem>,
|
pub queue: Vec<QueueItem>,
|
||||||
@@ -102,6 +109,12 @@ pub struct App {
|
|||||||
pub search_mode: bool,
|
pub search_mode: bool,
|
||||||
pub show_details: bool,
|
pub show_details: bool,
|
||||||
pub input_mode: 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 {
|
impl Default for App {
|
||||||
@@ -115,6 +128,7 @@ impl Default for App {
|
|||||||
history_table_state: TableState::default(),
|
history_table_state: TableState::default(),
|
||||||
health_list_state: ListState::default(),
|
health_list_state: ListState::default(),
|
||||||
search_list_state: ListState::default(),
|
search_list_state: ListState::default(),
|
||||||
|
settings_list_state: ListState::default(),
|
||||||
series: Vec::new(),
|
series: Vec::new(),
|
||||||
episodes: Vec::new(),
|
episodes: Vec::new(),
|
||||||
queue: Vec::new(),
|
queue: Vec::new(),
|
||||||
@@ -130,8 +144,15 @@ impl Default for App {
|
|||||||
search_mode: false,
|
search_mode: false,
|
||||||
show_details: false,
|
show_details: false,
|
||||||
input_mode: 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.series_list_state.select(Some(0));
|
||||||
|
app.settings_list_state.select(Some(0));
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +236,9 @@ impl App {
|
|||||||
self.search_list_state.select(Some(i));
|
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));
|
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) {
|
pub fn set_error(&mut self, error: String) {
|
||||||
self.error_message = Some(error);
|
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 async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Error>> {
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
// Setup terminal
|
// Setup terminal
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@@ -373,6 +599,7 @@ pub async fn run_app(client: SonarrClient) -> Result<(), Box<dyn std::error::Err
|
|||||||
|
|
||||||
// Create app and run it
|
// Create app and run it
|
||||||
let mut app = App::default();
|
let mut app = App::default();
|
||||||
|
app.config = config;
|
||||||
let res = run_tui(&mut terminal, &mut app, client).await;
|
let res = run_tui(&mut terminal, &mut app, client).await;
|
||||||
|
|
||||||
// Restore terminal
|
// Restore terminal
|
||||||
@@ -588,9 +815,28 @@ async fn handle_input(
|
|||||||
tx: mpsc::UnboundedSender<AppEvent>,
|
tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
) {
|
) {
|
||||||
if app.input_mode {
|
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<AppEvent>,
|
||||||
|
) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if !app.search_input.is_empty() {
|
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;
|
app.loading = true;
|
||||||
let search_term = app.search_input.clone();
|
let search_term = app.search_input.clone();
|
||||||
let client_clone = client.clone();
|
let client_clone = client.clone();
|
||||||
@@ -601,43 +847,89 @@ async fn handle_input(
|
|||||||
let _ = tx_clone.send(AppEvent::SearchResults(results));
|
let _ = tx_clone.send(AppEvent::SearchResults(results));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ =
|
let _ = tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
|
||||||
tx_clone.send(AppEvent::Error(format!("Search failed: {}", e)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
app.input_mode = false;
|
app.input_mode = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
if app.editing_url || app.editing_api_key {
|
||||||
|
app.cancel_editing();
|
||||||
|
} else {
|
||||||
app.exit_search_mode();
|
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);
|
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();
|
app.search_input.pop();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
async fn handle_normal_mode(
|
||||||
|
app: &mut App,
|
||||||
|
key: crossterm::event::KeyEvent,
|
||||||
|
client: SonarrClient,
|
||||||
|
tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => app.should_quit = true,
|
KeyCode::Char('q') => app.should_quit = true,
|
||||||
KeyCode::Tab => app.next_tab(),
|
KeyCode::Tab => app.next_tab(),
|
||||||
KeyCode::BackTab => app.previous_tab(),
|
KeyCode::BackTab => app.previous_tab(),
|
||||||
KeyCode::Down | KeyCode::Char('j') => app.next_item(),
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
KeyCode::Up | KeyCode::Char('k') => app.previous_item(),
|
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('d') => app.toggle_details(),
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
app.loading = true;
|
app.loading = true;
|
||||||
app.clear_error();
|
app.clear_error();
|
||||||
load_all_data(client, tx);
|
tokio::spawn(async move {
|
||||||
|
load_all_data(client, tx).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
KeyCode::Char('/') => {
|
KeyCode::Char('/') => {
|
||||||
if app.current_tab == TabIndex::Search {
|
if app.current_tab == TabIndex::Search {
|
||||||
app.enter_search_mode();
|
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 => {
|
KeyCode::Esc => {
|
||||||
app.clear_error();
|
app.clear_error();
|
||||||
if app.search_mode {
|
if app.search_mode {
|
||||||
@@ -647,6 +939,85 @@ async fn handle_input(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_vim_mode(
|
||||||
|
app: &mut App,
|
||||||
|
key: crossterm::event::KeyEvent,
|
||||||
|
client: SonarrClient,
|
||||||
|
tx: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
) {
|
||||||
|
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) {
|
fn ui(f: &mut Frame, app: &App) {
|
||||||
@@ -673,6 +1044,7 @@ fn ui(f: &mut Frame, app: &App) {
|
|||||||
TabIndex::History => render_history_tab(f, chunks[1], app),
|
TabIndex::History => render_history_tab(f, chunks[1], app),
|
||||||
TabIndex::Health => render_health_tab(f, chunks[1], app),
|
TabIndex::Health => render_health_tab(f, chunks[1], app),
|
||||||
TabIndex::Search => render_search_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
|
||||||
@@ -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) {
|
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||||
let help_text = if app.input_mode {
|
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 {
|
} else {
|
||||||
"q: Quit | Tab: Next Tab | ↑↓/jk: Navigate | d: Details | r: Refresh | /: Search (in Search tab)"
|
"ESC: Cancel | Enter: Search | Type to enter search term"
|
||||||
|
}
|
||||||
|
} else if !app.config.ui.show_help {
|
||||||
|
"" // Don't show help if disabled
|
||||||
|
} 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)];
|
let mut spans = vec![Span::raw(help_text)];
|
||||||
|
|||||||
@@ -6,15 +6,36 @@
|
|||||||
[sonarr]
|
[sonarr]
|
||||||
# Sonarr server URL (required)
|
# Sonarr server URL (required)
|
||||||
# Example: "http://localhost:8989" or "https://sonarr.example.com"
|
# Example: "http://localhost:8989" or "https://sonarr.example.com"
|
||||||
|
# Can also be edited from the Settings tab in the application
|
||||||
url = "http://localhost:8989"
|
url = "http://localhost:8989"
|
||||||
|
|
||||||
# Sonarr API key (required)
|
# Sonarr API key (required)
|
||||||
# You can find this in Sonarr under Settings > General > Security > API Key
|
# 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"
|
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:
|
# Environment variables can also be used:
|
||||||
# YARR_SONARR_URL="http://localhost:8989"
|
# YARR_SONARR_URL="http://localhost:8989"
|
||||||
# YARR_SONARR_API_KEY="your-api-key-here"
|
# YARR_SONARR_API_KEY="your-api-key-here"
|
||||||
|
# YARR_UI_KEYBIND_MODE="Vim"
|
||||||
|
# YARR_UI_SHOW_HELP="false"
|
||||||
#
|
#
|
||||||
# Command line arguments take highest priority:
|
# Command line arguments take highest priority:
|
||||||
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"
|
# yarr --sonarr-url="http://localhost:8989" --sonarr-api-key="your-key"
|
||||||
|
|||||||
Reference in New Issue
Block a user