From 8870478cc67a0977428b76c3784f27e5797fe400 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Wed, 9 Jul 2025 18:05:08 +0300 Subject: [PATCH] feat(move): add move functionality --- config/default.toml | 1 + src/app/action.rs | 4 +- src/app/command.rs | 41 +++----- src/app/mod.rs | 23 ++++- src/app/torrent.rs | 19 +++- src/app/utils/mod.rs | 4 +- src/config/keybinds.rs | 2 + src/event.rs | 2 +- src/handler.rs | 213 +++++++++++++++++++++++------------------ src/log.rs | 2 +- src/main.rs | 4 +- src/tui.rs | 2 +- src/ui/input.rs | 25 +++++ src/ui/mod.rs | 5 + 14 files changed, 211 insertions(+), 136 deletions(-) create mode 100644 src/ui/input.rs diff --git a/config/default.toml b/config/default.toml index 02931f0..08c1aa3 100644 --- a/config/default.toml +++ b/config/default.toml @@ -13,6 +13,7 @@ delete = "d" delete_force = "D" select = " " toggle_help = "?" +move = "m" [colors] highlight_background = "magenta" diff --git a/src/app/action.rs b/src/app/action.rs index 11eb6a6..dbec8c0 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -6,7 +6,7 @@ pub enum Action { NextTorrent, PrevTorrent, SwitchTab(u8), - ToggleHelp, // Add this line + ToggleHelp, ToggleTorrent, ToggleAll, PauseAll, @@ -15,4 +15,6 @@ pub enum Action { Delete(bool), Rename, Select, + Submit, + Cancel, } diff --git a/src/app/command.rs b/src/app/command.rs index 4827cc7..26097d0 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,9 +1,10 @@ use super::{types::Selected, Torrents}; +use color_eyre::{eyre::eyre, Result}; use std::{collections::HashSet, path::Path}; use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus}; impl Torrents { - pub async fn toggle(&mut self, ids: Selected) -> color_eyre::eyre::Result<()> { + pub async fn toggle(&mut self, ids: Selected) -> Result<()> { let ids: HashSet<_> = ids.into(); let torrents_to_toggle: Vec<_> = self .torrents @@ -20,15 +21,13 @@ impl Torrents { self.client .torrent_action(action, vec![id]) .await - .map_err(|e| { - color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()) - })?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; } } Ok(()) } - pub async fn toggle_all(&mut self) -> color_eyre::eyre::Result<()> { + pub async fn toggle_all(&mut self) -> Result<()> { let torrents_to_toggle: Vec<_> = self .torrents .iter() @@ -49,18 +48,16 @@ impl Torrents { self.client .torrent_action(action, vec![id]) .await - .map_err(|e| { - color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()) - })?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - pub async fn start_all(&mut self) -> color_eyre::eyre::Result<()> { + pub async fn start_all(&mut self) -> Result<()> { self.action_all(TorrentAction::StartNow).await } - pub async fn stop_all(&mut self) -> color_eyre::eyre::Result<()> { + pub async fn stop_all(&mut self) -> Result<()> { self.action_all(TorrentAction::Stop).await } @@ -69,43 +66,35 @@ impl Torrents { torrent: &Torrent, location: &Path, move_from: Option, - ) -> color_eyre::eyre::Result<()> { + ) -> Result<()> { if let Some(id) = torrent.id() { self.client .torrent_set_location(vec![id], location.to_string_lossy().into(), move_from) .await - .map_err(|e| { - color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()) - })?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - pub async fn delete( - &mut self, - ids: Selected, - delete_local_data: bool, - ) -> color_eyre::eyre::Result<()> { + pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> { self.client .torrent_remove(ids.into(), delete_local_data) .await - .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; Ok(()) } - pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> color_eyre::eyre::Result<()> { + pub async fn rename(&mut self, torrent: &Torrent, name: &Path) -> Result<()> { if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) { self.client .torrent_rename_path(vec![id], old_name, name.to_string_lossy().into()) .await - .map_err(|e| { - color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()) - })?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; } Ok(()) } - async fn action_all(&mut self, action: TorrentAction) -> color_eyre::eyre::Result<()> { + async fn action_all(&mut self, action: TorrentAction) -> Result<()> { let ids = self .torrents .iter() @@ -115,7 +104,7 @@ impl Torrents { self.client .torrent_action(action, ids) .await - .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))?; + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; Ok(()) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index e151995..9551cd6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,6 +6,7 @@ pub mod types; pub mod utils; use crate::config::Config; +use color_eyre::Result; use ratatui::widgets::TableState; use types::Selected; pub use {tab::Tab, torrent::Torrents}; @@ -20,12 +21,15 @@ pub struct App<'a> { pub torrents: Torrents, pub show_help: bool, pub config: Config, + pub input: String, + pub cursor_position: usize, + pub input_mode: bool, } impl<'a> App<'a> { /// Constructs a new instance of [`App`]. /// Returns instance of `Self`. - pub fn new(config: Config) -> color_eyre::eyre::Result { + pub fn new(config: Config) -> Result { Ok(Self { running: true, tabs: &[Tab::All, Tab::Active, Tab::Downloading], @@ -34,11 +38,14 @@ impl<'a> App<'a> { torrents: Torrents::new()?, // Handle the Result here show_help: false, config, + input: String::new(), + cursor_position: 0, + input_mode: false, }) } /// Handles the tick event of the terminal. - pub async fn tick(&mut self) -> color_eyre::eyre::Result<()> { + pub async fn tick(&mut self) -> Result<()> { self.torrents.update().await?; Ok(()) } @@ -122,20 +129,28 @@ impl<'a> App<'a> { self.show_help = true; } - pub async fn toggle_torrents(&mut self) -> color_eyre::eyre::Result<()> { + pub async fn toggle_torrents(&mut self) -> Result<()> { let ids = self.selected(false); self.torrents.toggle(ids).await?; self.close_help(); Ok(()) } - pub async fn delete(&mut self, delete_local_data: bool) -> color_eyre::eyre::Result<()> { + pub async fn delete(&mut self, delete_local_data: bool) -> Result<()> { let ids = self.selected(false); self.torrents.delete(ids, delete_local_data).await?; self.close_help(); Ok(()) } + pub async fn move_torrent(&mut self) -> Result<()> { + self.torrents.move_selection(&self.input).await?; + self.input.clear(); + self.cursor_position = 0; + self.input_mode = false; + Ok(()) + } + pub fn select(&mut self) { if let Selected::Current(current_id) = self.selected(true) { if self.torrents.selected.contains(¤t_id) { diff --git a/src/app/torrent.rs b/src/app/torrent.rs index c310dcf..7862602 100644 --- a/src/app/torrent.rs +++ b/src/app/torrent.rs @@ -1,7 +1,7 @@ -use color_eyre::eyre::Result; +use color_eyre::{eyre::eyre, Result}; use std::{collections::HashSet, fmt::Debug}; use transmission_rpc::{ - types::{Torrent, TorrentGetField}, + types::{Id, Torrent, TorrentGetField}, TransClient, }; use url::Url; @@ -50,11 +50,20 @@ impl Torrents { .client .torrent_get(self.fields.clone(), None) .await - .map_err(|e| color_eyre::eyre::eyre!("Transmission RPC error: {}", e.to_string()))? + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))? .arguments .torrents; Ok(self) } + + pub async fn move_selection(&mut self, location: &str) -> Result<()> { + let ids: Vec = self.selected.iter().map(|id| Id::Id(*id)).collect(); + self.client + .torrent_set_location(ids, location.to_string(), Some(true)) + .await + .map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?; + Ok(()) + } } impl Debug for Torrents { @@ -66,7 +75,9 @@ impl Debug for Torrents { write!( f, "fields: - {:?};\n\ntorrents: {:?}", +{:?}; + +torrents: {:?}", fields, self.torrents ) } diff --git a/src/app/utils/mod.rs b/src/app/utils/mod.rs index 9b7c028..b84ec36 100644 --- a/src/app/utils/mod.rs +++ b/src/app/utils/mod.rs @@ -11,11 +11,11 @@ use transmission_rpc::types::{ pub trait Wrapper { fn title(&self) -> String { - "".to_string() + String::new() } fn value(&self, torrent: &Torrent) -> String { - format!("{}", torrent.name.as_ref().unwrap_or(&String::from(""))) + torrent.name.clone().unwrap_or_default() } fn width(&self) -> u16 { diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 502d1ca..a41b5e0 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -18,6 +18,7 @@ pub struct KeybindsConfig { pub delete_force: Option, pub select: Option, pub toggle_help: Option, + pub move_torrent: Option, } impl Default for KeybindsConfig { @@ -37,6 +38,7 @@ impl Default for KeybindsConfig { delete_force: Some("D".to_string()), select: Some(" ".to_string()), toggle_help: Some("?".to_string()), + move_torrent: Some("m".to_string()), } } } diff --git a/src/event.rs b/src/event.rs index 302cfe6..f536760 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,4 +1,4 @@ -use color_eyre::eyre::Result; +use color_eyre::Result; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; use std::sync::mpsc; use std::thread; diff --git a/src/handler.rs b/src/handler.rs index bfa33b0..3e41fbb 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,109 +1,68 @@ use crate::app::{action::Action, App}; +use color_eyre::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tracing::{event, info_span, Level}; -/// Handles the key events of [`App`]. -#[tracing::instrument] -pub fn get_action(key_event: KeyEvent, app: &App) -> Option { - let span = info_span!("get_action"); - let _enter = span.enter(); - event!(Level::INFO, "handling key event: {:?}", key_event); - - let config_keybinds = &app.config.keybinds; - - // Helper to check if a KeyEvent matches a configured keybind string - let matches_keybind = |event: &KeyEvent, config_key: &Option| { - if let Some(key_str) = config_key { - let parts: Vec<&str> = key_str.split('+').collect(); - let mut parsed_modifiers = KeyModifiers::NONE; - let mut parsed_key_code = None; - - for part in &parts { - match part.to_lowercase().as_str() { - "ctrl" => parsed_modifiers.insert(KeyModifiers::CONTROL), - "alt" => parsed_modifiers.insert(KeyModifiers::ALT), - "shift" => parsed_modifiers.insert(KeyModifiers::SHIFT), - "esc" => parsed_key_code = Some(KeyCode::Esc), - "enter" => parsed_key_code = Some(KeyCode::Enter), - "left" => parsed_key_code = Some(KeyCode::Left), - "right" => parsed_key_code = Some(KeyCode::Right), - "up" => parsed_key_code = Some(KeyCode::Up), - "down" => parsed_key_code = Some(KeyCode::Down), - "tab" => parsed_key_code = Some(KeyCode::Tab), - "backspace" => parsed_key_code = Some(KeyCode::Backspace), - "delete" => parsed_key_code = Some(KeyCode::Delete), - "home" => parsed_key_code = Some(KeyCode::Home), - "end" => parsed_key_code = Some(KeyCode::End), - "pageup" => parsed_key_code = Some(KeyCode::PageUp), - "pagedown" => parsed_key_code = Some(KeyCode::PageDown), - "null" => parsed_key_code = Some(KeyCode::Null), - "insert" => parsed_key_code = Some(KeyCode::Insert), - _ => { - if part.len() == 1 { - if let Some(c) = part.chars().next() { - parsed_key_code = Some(KeyCode::Char(c)); - } else { - return false; - } - } else if part.starts_with("f") && part.len() > 1 { - if let Ok(f_num) = part[1..].parse::() { - parsed_key_code = Some(KeyCode::F(f_num)); - } else { - return false; - } - } else { - return false; - } - } - } - } - - if parsed_key_code.is_none() { - return false; - } - - event.code == parsed_key_code.unwrap() && event.modifiers == parsed_modifiers - } else { - false - } - }; - +fn handle_input(key_event: KeyEvent, app: &mut App) -> Option { match key_event.code { - _ if matches_keybind(&key_event, &config_keybinds.quit) => Some(Action::Quit), - _ if matches_keybind(&key_event, &config_keybinds.next_tab) => Some(Action::NextTab), - _ if matches_keybind(&key_event, &config_keybinds.prev_tab) => Some(Action::PrevTab), - _ if matches_keybind(&key_event, &config_keybinds.next_torrent) => { - Some(Action::NextTorrent) + KeyCode::Enter => Some(Action::Submit), + KeyCode::Char(c) => { + app.input.push(c); + app.cursor_position = app.input.len(); + None } - _ if matches_keybind(&key_event, &config_keybinds.prev_torrent) => { - Some(Action::PrevTorrent) + KeyCode::Backspace => { + app.input.pop(); + app.cursor_position = app.input.len(); + None } - _ if matches_keybind(&key_event, &config_keybinds.switch_tab_1) => { - Some(Action::SwitchTab(0)) - } - _ if matches_keybind(&key_event, &config_keybinds.switch_tab_2) => { - Some(Action::SwitchTab(1)) - } - _ if matches_keybind(&key_event, &config_keybinds.switch_tab_3) => { - Some(Action::SwitchTab(2)) - } - _ if matches_keybind(&key_event, &config_keybinds.toggle_torrent) => { - Some(Action::ToggleTorrent) - } - _ if matches_keybind(&key_event, &config_keybinds.toggle_all) => Some(Action::ToggleAll), - _ if matches_keybind(&key_event, &config_keybinds.delete) => Some(Action::Delete(false)), - _ if matches_keybind(&key_event, &config_keybinds.delete_force) => { - Some(Action::Delete(true)) - } - _ if matches_keybind(&key_event, &config_keybinds.select) => Some(Action::Select), - _ if matches_keybind(&key_event, &config_keybinds.toggle_help) => Some(Action::ToggleHelp), + KeyCode::Esc => Some(Action::Cancel), _ => None, } } +/// Handles the key events of [`App`]. +#[tracing::instrument] +pub fn get_action(key_event: KeyEvent, app: &mut App) -> Option { + if app.input_mode { + return handle_input(key_event, app); + } + + let span = info_span!("get_action"); + let _enter = span.enter(); + event!(Level::INFO, "handling key event: {:?}", key_event); + + let keybinds = &app.config.keybinds; + + let actions = [ + (Action::Quit, &keybinds.quit), + (Action::NextTab, &keybinds.next_tab), + (Action::PrevTab, &keybinds.prev_tab), + (Action::NextTorrent, &keybinds.next_torrent), + (Action::PrevTorrent, &keybinds.prev_torrent), + (Action::SwitchTab(0), &keybinds.switch_tab_1), + (Action::SwitchTab(1), &keybinds.switch_tab_2), + (Action::SwitchTab(2), &keybinds.switch_tab_3), + (Action::ToggleTorrent, &keybinds.toggle_torrent), + (Action::ToggleAll, &keybinds.toggle_all), + (Action::Delete(false), &keybinds.delete), + (Action::Delete(true), &keybinds.delete_force), + (Action::Select, &keybinds.select), + (Action::ToggleHelp, &keybinds.toggle_help), + (Action::Move, &keybinds.move_torrent), + ]; + + for (action, keybind) in actions { + if matches_keybind(&key_event, keybind) { + return Some(action); + } + } + None +} + /// Handles the updates of [`App`]. #[tracing::instrument] -pub async fn update(app: &mut App<'_>, action: Action) -> color_eyre::eyre::Result<()> { +pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> { let span = info_span!("update"); let _enter = span.enter(); event!(Level::INFO, "updating app with action: {:?}", action); @@ -119,10 +78,76 @@ pub async fn update(app: &mut App<'_>, action: Action) -> color_eyre::eyre::Resu Action::ToggleAll => app.torrents.toggle_all().await?, Action::PauseAll => app.torrents.stop_all().await?, Action::StartAll => app.torrents.start_all().await?, - Action::Move => unimplemented!(), + Action::Move => app.input_mode = true, Action::Delete(x) => app.delete(x).await?, Action::Rename => unimplemented!(), Action::Select => app.select(), + Action::Submit => app.move_torrent().await?, + Action::Cancel => { + app.input.clear(); + app.input_mode = false; + } } Ok(()) } + +/// Check if a KeyEvent matches a configured keybind string +fn matches_keybind(event: &KeyEvent, config_key: &Option) -> bool { + let Some(key_str) = config_key else { + return false; + }; + + let (modifiers, key_code) = parse_keybind(key_str); + let Some(key_code) = key_code else { + return false; + }; + + event.code == key_code && event.modifiers == modifiers +} + +fn parse_keybind(key_str: &str) -> (KeyModifiers, Option) { + let mut modifiers = KeyModifiers::NONE; + let mut key_code = None; + + for part in key_str.split('+') { + match part.trim().to_lowercase().as_str() { + "ctrl" => modifiers.insert(KeyModifiers::CONTROL), + "alt" => modifiers.insert(KeyModifiers::ALT), + "shift" => modifiers.insert(KeyModifiers::SHIFT), + key @ ("esc" | "enter" | "left" | "right" | "up" | "down" | "tab" | "backspace" + | "delete" | "home" | "end" | "pageup" | "pagedown" | "null" | "insert") => { + key_code = Some(match key { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "tab" => KeyCode::Tab, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "null" => KeyCode::Null, + "insert" => KeyCode::Insert, + _ => unreachable!(), + }); + } + f_key if f_key.starts_with('f') => { + if let Ok(num) = f_key[1..].parse::() { + key_code = Some(KeyCode::F(num)); + } + } + + single_char if single_char.len() == 1 => { + if let Some(c) = single_char.chars().next() { + key_code = Some(KeyCode::Char(c)); + } + } + _ => return (modifiers, None), + } + } + (modifiers, key_code) +} diff --git a/src/log.rs b/src/log.rs index 8ee0b11..4249ffb 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,4 +1,4 @@ -use color_eyre::eyre::Result; +use color_eyre::Result; use tracing_appender::rolling; use tracing_subscriber::{self, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; diff --git a/src/main.rs b/src/main.rs index c7caec6..13c0fea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ mod log; -use color_eyre::eyre::Result; +use color_eyre::Result; use log::setup_logger; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; @@ -40,7 +40,7 @@ async fn main() -> Result<()> { match tui.events.next()? { Event::Tick => app.tick().await?, Event::Key(key_event) => { - if let Some(action) = get_action(key_event, &app) { + if let Some(action) = get_action(key_event, &mut app) { update(&mut app, action).await?; } } diff --git a/src/tui.rs b/src/tui.rs index 91b0a8d..c663b70 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::event::EventHandler; use crate::ui; -use color_eyre::eyre::Result; +use color_eyre::Result; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::backend::Backend; diff --git a/src/ui/input.rs b/src/ui/input.rs new file mode 100644 index 0000000..b35c109 --- /dev/null +++ b/src/ui/input.rs @@ -0,0 +1,25 @@ +use crate::app::App; +use ratatui::{prelude::*, widgets::*}; + +pub fn render(f: &mut Frame, app: &mut App) { + let size = f.area(); + let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3); + + let block = Block::default().title("Move to").borders(Borders::ALL); + f.render_widget(Clear, input_area); + f.render_widget(block, input_area); + + let input = Paragraph::new(app.input.as_str()).block(Block::default()); + f.render_widget( + input, + input_area.inner(Margin { + vertical: 1, + horizontal: 1, + }), + ); + + f.set_cursor_position(ratatui::layout::Position::new( + input_area.x + app.cursor_position as u16 + 1, + input_area.y + 1, + )); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab9c3ad..73c19e9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ mod help; +mod input; mod table; use crate::app::{App, Tab}; @@ -63,4 +64,8 @@ pub fn render(app: &mut App, frame: &mut Frame) { if app.show_help { render_help(frame, app); } + + if app.input_mode { + input::render(frame, app); + } }