feat(tui): add vim-like keybinds and settings tab for config edit
This commit is contained in:
@@ -172,7 +172,7 @@ impl SonarrClient {
|
||||
}
|
||||
|
||||
pub async fn search_series(&self, term: &str) -> Result<Vec<Series>> {
|
||||
self.get_debug(&format!(
|
||||
self.get(&format!(
|
||||
"/series/lookup?term={}",
|
||||
urlencoding::encode(term)
|
||||
))
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -61,7 +61,7 @@ pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
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
|
||||
|
||||
603
src/tui.rs
603
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<Series>,
|
||||
pub episodes: Vec<Episode>,
|
||||
pub queue: Vec<QueueItem>,
|
||||
@@ -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<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) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub async fn run_app(
|
||||
client: SonarrClient,
|
||||
config: AppConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
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
|
||||
let mut app = App::default();
|
||||
app.config = config;
|
||||
let res = run_tui(&mut terminal, &mut app, client).await;
|
||||
|
||||
// Restore terminal
|
||||
@@ -588,64 +815,208 @@ async fn handle_input(
|
||||
tx: mpsc::UnboundedSender<AppEvent>,
|
||||
) {
|
||||
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<AppEvent>,
|
||||
) {
|
||||
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<AppEvent>,
|
||||
) {
|
||||
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<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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
Reference in New Issue
Block a user