feat(tui): add vim-like keybinds and settings tab for config edit
Some checks failed
build / checks-build (push) Has been cancelled
build / codecov (push) Has been cancelled
docs / docs (push) Has been cancelled
build / checks-matrix (push) Has been cancelled

This commit is contained in:
uttarayan21
2025-10-08 16:11:41 +05:30
parent 48e26332a3
commit a8f0ab160e
6 changed files with 660 additions and 55 deletions

View File

@@ -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)];