Add advanced features: search, sort, multi-select, missing QB actions, advanced add modal, notifications, file logging, and JSON parsing tests
This commit is contained in:
parent
1ef01a1a64
commit
b78e876e5a
|
|
@ -44,4 +44,10 @@ tui-input = "0.8"
|
|||
# Date formatting for details panel
|
||||
chrono = "0.4"
|
||||
|
||||
# Desktop notifications
|
||||
notify-rust = "4.11"
|
||||
|
||||
# File logging
|
||||
simplelog = { version = "0.12", features = ["paris"] }
|
||||
|
||||
|
||||
|
|
|
|||
141
src/app.rs
141
src/app.rs
|
|
@ -1,9 +1,43 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use tui_input::Input;
|
||||
|
||||
use crate::events::{
|
||||
Category, NetworkEvent, Peer, Torrent, TorrentFile, TorrentProperties, Tracker, TransferInfo,
|
||||
};
|
||||
|
||||
/// Which column the torrent list is currently sorted by.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortColumn {
|
||||
Name,
|
||||
Size,
|
||||
Progress,
|
||||
Status,
|
||||
Ratio,
|
||||
DlSpeed,
|
||||
UlSpeed,
|
||||
Seeds,
|
||||
Leechers,
|
||||
Popularity,
|
||||
}
|
||||
|
||||
impl SortColumn {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
SortColumn::Name => SortColumn::Size,
|
||||
SortColumn::Size => SortColumn::Progress,
|
||||
SortColumn::Progress => SortColumn::Status,
|
||||
SortColumn::Status => SortColumn::Ratio,
|
||||
SortColumn::Ratio => SortColumn::DlSpeed,
|
||||
SortColumn::DlSpeed => SortColumn::UlSpeed,
|
||||
SortColumn::UlSpeed => SortColumn::Seeds,
|
||||
SortColumn::Seeds => SortColumn::Leechers,
|
||||
SortColumn::Leechers => SortColumn::Popularity,
|
||||
SortColumn::Popularity => SortColumn::Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current high-level screen of the application.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
|
|
@ -69,6 +103,26 @@ pub enum DetailsTab {
|
|||
Peers,
|
||||
}
|
||||
|
||||
/// Focus within the add torrent advanced modal.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AddModalFocus {
|
||||
Url,
|
||||
Path,
|
||||
Category,
|
||||
Tags,
|
||||
}
|
||||
|
||||
impl AddModalFocus {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
AddModalFocus::Url => AddModalFocus::Path,
|
||||
AddModalFocus::Path => AddModalFocus::Category,
|
||||
AddModalFocus::Category => AddModalFocus::Tags,
|
||||
AddModalFocus::Tags => AddModalFocus::Url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DetailsTab {
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
|
|
@ -125,9 +179,26 @@ pub struct App {
|
|||
pub properties: Option<TorrentProperties>,
|
||||
pub peers: Vec<Peer>,
|
||||
|
||||
// Search state
|
||||
pub show_search: bool,
|
||||
pub search_input: Input,
|
||||
|
||||
// Sorting state
|
||||
pub sort_column: SortColumn,
|
||||
pub sort_ascending: bool,
|
||||
|
||||
// Multi-selection state
|
||||
pub selected_hashes: HashSet<String>,
|
||||
|
||||
// Add modal state
|
||||
pub show_add_modal: bool,
|
||||
pub add_modal_focus: AddModalFocus,
|
||||
pub add_input: Input,
|
||||
pub add_path_input: Input,
|
||||
pub add_category_input: Input,
|
||||
pub add_tags_input: Input,
|
||||
pub add_paused: bool,
|
||||
pub add_skip_checking: bool,
|
||||
|
||||
// Delete modal state
|
||||
pub show_delete_confirm: bool,
|
||||
|
|
@ -180,8 +251,22 @@ impl App {
|
|||
properties: None,
|
||||
peers: Vec::new(),
|
||||
|
||||
show_search: false,
|
||||
search_input: Input::default(),
|
||||
|
||||
sort_column: SortColumn::Name,
|
||||
sort_ascending: true,
|
||||
|
||||
selected_hashes: HashSet::new(),
|
||||
|
||||
show_add_modal: false,
|
||||
add_modal_focus: AddModalFocus::Url,
|
||||
add_input: Input::default(),
|
||||
add_path_input: Input::default(),
|
||||
add_category_input: Input::default(),
|
||||
add_tags_input: Input::default(),
|
||||
add_paused: false,
|
||||
add_skip_checking: false,
|
||||
|
||||
show_delete_confirm: false,
|
||||
delete_with_files: false,
|
||||
|
|
@ -212,10 +297,13 @@ impl App {
|
|||
self.sidebar_selected = self.sidebar_selected.min(self.sidebar_items.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
/// Return the torrents visible after applying the current sidebar filter.
|
||||
/// Return the torrents visible after applying sidebar filter, search and sort.
|
||||
pub fn filtered_torrents(&self) -> Vec<&Torrent> {
|
||||
let filter = self.sidebar_items.get(self.sidebar_selected);
|
||||
self.torrents
|
||||
let search = self.search_input.value().to_lowercase();
|
||||
|
||||
let mut filtered: Vec<&Torrent> = self
|
||||
.torrents
|
||||
.iter()
|
||||
.filter(|t| match filter {
|
||||
Some(SidebarFilter::All) => true,
|
||||
|
|
@ -241,7 +329,32 @@ impl App {
|
|||
Some(SidebarFilter::Tag(tag)) => t.tags.contains(tag),
|
||||
None => true,
|
||||
})
|
||||
.collect()
|
||||
.filter(|t| {
|
||||
if search.is_empty() {
|
||||
true
|
||||
} else {
|
||||
t.name.to_lowercase().contains(&search)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
filtered.sort_by(|a, b| {
|
||||
let ord = match self.sort_column {
|
||||
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
SortColumn::Size => a.size.cmp(&b.size),
|
||||
SortColumn::Progress => a.progress.partial_cmp(&b.progress).unwrap_or(std::cmp::Ordering::Equal),
|
||||
SortColumn::Status => a.status.cmp(&b.status),
|
||||
SortColumn::Ratio => a.ratio.partial_cmp(&b.ratio).unwrap_or(std::cmp::Ordering::Equal),
|
||||
SortColumn::DlSpeed => a.dl_speed.cmp(&b.dl_speed),
|
||||
SortColumn::UlSpeed => a.ul_speed.cmp(&b.ul_speed),
|
||||
SortColumn::Seeds => a.num_seeds.cmp(&b.num_seeds),
|
||||
SortColumn::Leechers => a.num_leechs.cmp(&b.num_leechs),
|
||||
SortColumn::Popularity => a.popularity.partial_cmp(&b.popularity).unwrap_or(std::cmp::Ordering::Equal),
|
||||
};
|
||||
if self.sort_ascending { ord } else { ord.reverse() }
|
||||
});
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Get the hash of the torrent currently selected in the *filtered* list.
|
||||
|
|
@ -386,6 +499,28 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn toggle_select_current(&mut self) {
|
||||
if let Some(hash) = self.selected_torrent_hash() {
|
||||
if self.selected_hashes.contains(&hash) {
|
||||
self.selected_hashes.remove(&hash);
|
||||
} else {
|
||||
self.selected_hashes.insert(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selected_hashes.clear();
|
||||
}
|
||||
|
||||
pub fn next_sort_column(&mut self) {
|
||||
self.sort_column = self.sort_column.next();
|
||||
}
|
||||
|
||||
pub fn toggle_sort_direction(&mut self) {
|
||||
self.sort_ascending = !self.sort_ascending;
|
||||
}
|
||||
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
|
|
|
|||
108
src/events.rs
108
src/events.rs
|
|
@ -44,6 +44,15 @@ pub enum Command {
|
|||
AddMagnet(String),
|
||||
/// Add a torrent from a local .torrent file path
|
||||
AddFile(String),
|
||||
/// Add torrent(s) with advanced options
|
||||
AddTorrentAdvanced {
|
||||
urls: String,
|
||||
save_path: String,
|
||||
category: String,
|
||||
tags: Vec<String>,
|
||||
paused: bool,
|
||||
skip_checking: bool,
|
||||
},
|
||||
/// Toggle global speed limits mode
|
||||
ToggleSpeedLimits,
|
||||
/// Fetch trackers for a given hash
|
||||
|
|
@ -54,6 +63,26 @@ pub enum Command {
|
|||
FetchProperties(String),
|
||||
/// Fetch peers for a given hash
|
||||
FetchPeers(String),
|
||||
/// Recheck torrents by hash
|
||||
Recheck(Vec<String>),
|
||||
/// Relocate torrents to a new path
|
||||
Relocate { hashes: Vec<String>, new_path: String },
|
||||
/// Set upload/download limits for torrents
|
||||
SetTorrentLimits { hashes: Vec<String>, ul_limit: i64, dl_limit: i64 },
|
||||
/// Set category for torrents
|
||||
SetCategory { hashes: Vec<String>, category: String },
|
||||
/// Create a new category
|
||||
CreateCategory { name: String, save_path: String },
|
||||
/// Delete a category
|
||||
DeleteCategory(String),
|
||||
/// Add tags to torrents
|
||||
AddTags { hashes: Vec<String>, tags: Vec<String> },
|
||||
/// Create a new tag
|
||||
CreateTag(String),
|
||||
/// Delete a tag
|
||||
DeleteTag(String),
|
||||
/// Set file priority
|
||||
SetFilePriority { hash: String, file_ids: Vec<i64>, priority: i64 },
|
||||
/// Shutdown the network worker
|
||||
Shutdown,
|
||||
}
|
||||
|
|
@ -307,3 +336,82 @@ pub struct Peer {
|
|||
#[serde(default, deserialize_with = "deserialize_string_or_null")]
|
||||
pub files: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_torrent_defaults() {
|
||||
let json = r#"{"hash":"abc","name":"test"}"#;
|
||||
let t: Torrent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.hash, "abc");
|
||||
assert_eq!(t.name, "test");
|
||||
assert_eq!(t.size, 0);
|
||||
assert_eq!(t.progress, 0.0);
|
||||
assert_eq!(t.ratio, 0.0);
|
||||
assert_eq!(t.num_seeds, 0);
|
||||
assert_eq!(t.num_leechs, 0);
|
||||
assert_eq!(t.popularity, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_torrent_null_fields() {
|
||||
let json = r#"{"hash":"abc","name":"test","category":null,"tags":null,"comment":null}"#;
|
||||
let t: Torrent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.category, "");
|
||||
assert!(t.tags.is_empty());
|
||||
assert_eq!(t.comment, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_torrent_tags_array() {
|
||||
let json = r#"{"hash":"abc","name":"test","tags":["hd","movies"]}"#;
|
||||
let t: Torrent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.tags, vec!["hd", "movies"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_torrent_tags_string() {
|
||||
let json = r#"{"hash":"abc","name":"test","tags":"hd,movies"}"#;
|
||||
let t: Torrent = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.tags, vec!["hd", "movies"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tracker_defaults() {
|
||||
let json = r#"{"url":"http://tracker"}"#;
|
||||
let tr: Tracker = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(tr.url, "http://tracker");
|
||||
assert_eq!(tr.status, 0);
|
||||
assert_eq!(tr.num_peers, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_torrent_file_defaults() {
|
||||
let json = r#"{"name":"file.mkv"}"#;
|
||||
let f: TorrentFile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(f.name, "file.mkv");
|
||||
assert_eq!(f.size, 0);
|
||||
assert_eq!(f.progress, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_properties_defaults() {
|
||||
let json = r#"{"hash":"abc","name":"test"}"#;
|
||||
let p: TorrentProperties = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.hash, "abc");
|
||||
assert_eq!(p.popularity, 0.0);
|
||||
assert_eq!(p.num_seeds, 0);
|
||||
assert_eq!(p.num_leechs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_peer_defaults() {
|
||||
let json = r#"{"ip":"1.2.3.4"}"#;
|
||||
let peer: Peer = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(peer.ip, "1.2.3.4");
|
||||
assert_eq!(peer.port, 0);
|
||||
assert_eq!(peer.client, "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
src/main.rs
177
src/main.rs
|
|
@ -20,6 +20,7 @@ use crossterm::{
|
|||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use tokio::sync::mpsc;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
use simplelog::{Config, LevelFilter, WriteLogger};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::events::{Command, NetworkEvent};
|
||||
|
|
@ -29,6 +30,7 @@ use crate::ui::draw;
|
|||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
setup_panic_hook();
|
||||
setup_logging();
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
|
|
@ -89,6 +91,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_logging() {
|
||||
let log_dir = std::env::var("HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("."))
|
||||
.join(".local/share/qbit-tui");
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_file = log_dir.join("qbit-tui.log");
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_file)
|
||||
.unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap());
|
||||
let _ = WriteLogger::init(LevelFilter::Info, Config::default(), file);
|
||||
}
|
||||
|
||||
fn notify_completed(name: &str) {
|
||||
let _ = notify_rust::Notification::new()
|
||||
.summary("qbit-tui")
|
||||
.body(&format!("Download completed: {}", name))
|
||||
.timeout(notify_rust::Timeout::Milliseconds(5000))
|
||||
.show();
|
||||
}
|
||||
|
||||
fn setup_panic_hook() {
|
||||
let original_hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
|
|
@ -118,6 +143,19 @@ async fn run_app(
|
|||
}
|
||||
|
||||
Some(net_evt) = net_rx.recv() => {
|
||||
// Detect completed downloads before updating state
|
||||
if let NetworkEvent::TorrentList(ref list) = net_evt {
|
||||
let old_status: std::collections::HashMap<&str, &str> = app.torrents.iter().map(|t| (t.hash.as_str(), t.status.as_str())).collect();
|
||||
for t in list {
|
||||
let was_downloading = old_status.get(t.hash.as_str()).map(|s| {
|
||||
*s == "downloading" || *s == "stalledDL" || *s == "metaDL" || *s == "forcedDL" || *s == "checkingDL"
|
||||
}).unwrap_or(false);
|
||||
let is_completed = t.status == "uploading" || t.status == "stalledUP" || t.status == "pausedUP" || t.status == "stoppedUP" || t.status == "forcedUP";
|
||||
if was_downloading && is_completed {
|
||||
notify_completed(&t.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.handle_network_event(net_evt);
|
||||
// Switch from login to main on successful auth
|
||||
if app.screen == app::Screen::Login && app.authenticated {
|
||||
|
|
@ -133,7 +171,7 @@ async fn run_app(
|
|||
/// Render the frame and position the physical cursor when the add-modal is active.
|
||||
fn draw_and_cursor(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &App,
|
||||
app: &mut App,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut cursor_pos: Option<(u16, u16)> = None;
|
||||
terminal.draw(|f| draw(f, app, &mut cursor_pos))?;
|
||||
|
|
@ -232,24 +270,88 @@ async fn handle_input(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Add Magnet Modal ───────────────────────────────────────────
|
||||
// ── Search Mode ────────────────────────────────────────────────
|
||||
if app.show_search {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.show_search = false;
|
||||
app.search_input.reset();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.show_search = false;
|
||||
}
|
||||
_ => {
|
||||
app.search_input.handle_event(&evt);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Add Torrent Advanced Modal ─────────────────────────────────
|
||||
if app.show_add_modal {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.show_add_modal = false;
|
||||
app.add_input.reset();
|
||||
app.add_path_input.reset();
|
||||
app.add_category_input.reset();
|
||||
app.add_tags_input.reset();
|
||||
app.add_paused = false;
|
||||
app.add_skip_checking = false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let urls = app.add_input.value().to_string();
|
||||
if !urls.is_empty() {
|
||||
let _ = cmd_tx.send(Command::AddMagnet(urls)).await;
|
||||
let tags = app.add_tags_input.value().to_string();
|
||||
let tags_vec = if tags.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
tags.split(',').map(|t| t.trim().to_string()).collect()
|
||||
};
|
||||
let _ = cmd_tx.send(Command::AddTorrentAdvanced {
|
||||
urls,
|
||||
save_path: app.add_path_input.value().to_string(),
|
||||
category: app.add_category_input.value().to_string(),
|
||||
tags: tags_vec,
|
||||
paused: app.add_paused,
|
||||
skip_checking: app.add_skip_checking,
|
||||
}).await;
|
||||
}
|
||||
app.show_add_modal = false;
|
||||
app.add_input.reset();
|
||||
app.add_path_input.reset();
|
||||
app.add_category_input.reset();
|
||||
app.add_tags_input.reset();
|
||||
app.add_paused = false;
|
||||
app.add_skip_checking = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.add_modal_focus = app.add_modal_focus.next();
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
match app.add_modal_focus {
|
||||
app::AddModalFocus::Url => {}
|
||||
app::AddModalFocus::Path => {}
|
||||
app::AddModalFocus::Category => {}
|
||||
app::AddModalFocus::Tags => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match app.add_modal_focus {
|
||||
app::AddModalFocus::Url => {
|
||||
app.add_input.handle_event(&evt);
|
||||
}
|
||||
app::AddModalFocus::Path => {
|
||||
app.add_path_input.handle_event(&evt);
|
||||
}
|
||||
app::AddModalFocus::Category => {
|
||||
app.add_category_input.handle_event(&evt);
|
||||
}
|
||||
app::AddModalFocus::Tags => {
|
||||
app.add_tags_input.handle_event(&evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -258,15 +360,23 @@ async fn handle_input(
|
|||
if app.show_delete_confirm {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
if let Some(hash) = app.selected_torrent_hash() {
|
||||
let hashes = if !app.selected_hashes.is_empty() {
|
||||
app.selected_hashes.iter().cloned().collect()
|
||||
} else if let Some(hash) = app.selected_torrent_hash() {
|
||||
vec![hash]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if !hashes.is_empty() {
|
||||
let _ = cmd_tx
|
||||
.send(Command::Delete {
|
||||
hashes: vec![hash],
|
||||
hashes,
|
||||
delete_files: app.delete_with_files,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
app.show_delete_confirm = false;
|
||||
app.clear_selection();
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||
app.show_delete_confirm = false;
|
||||
|
|
@ -322,6 +432,29 @@ async fn handle_input(
|
|||
refresh_details(app, cmd_tx).await;
|
||||
}
|
||||
|
||||
// Search toggle
|
||||
KeyCode::Char('/') => {
|
||||
app.show_search = true;
|
||||
}
|
||||
|
||||
// Sorting
|
||||
KeyCode::Char('.') => {
|
||||
app.next_sort_column();
|
||||
app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" }));
|
||||
}
|
||||
KeyCode::Char(',') => {
|
||||
app.toggle_sort_direction();
|
||||
app.status_message = Some(format!("Sort by {:?} {}", app.sort_column, if app.sort_ascending { "asc" } else { "desc" }));
|
||||
}
|
||||
|
||||
// Multi-selection
|
||||
KeyCode::Char('s') => {
|
||||
app.toggle_select_current();
|
||||
}
|
||||
KeyCode::Char('S') => {
|
||||
app.clear_selection();
|
||||
}
|
||||
|
||||
// Add torrent file via native file dialog
|
||||
KeyCode::Char('a') => {
|
||||
let paths = file_dialog::pick_torrent_files();
|
||||
|
|
@ -340,6 +473,20 @@ async fn handle_input(
|
|||
let _ = cmd_tx.send(Command::ToggleSpeedLimits).await;
|
||||
}
|
||||
|
||||
// Recheck selected / current torrents
|
||||
KeyCode::Char('r') => {
|
||||
let hashes = if !app.selected_hashes.is_empty() {
|
||||
app.selected_hashes.iter().cloned().collect()
|
||||
} else if let Some(t) = app.selected_torrent() {
|
||||
vec![t.hash.clone()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if !hashes.is_empty() {
|
||||
let _ = cmd_tx.send(Command::Recheck(hashes)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation depends on current focus
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
match app.focus {
|
||||
|
|
@ -362,14 +509,14 @@ async fn handle_input(
|
|||
}
|
||||
}
|
||||
|
||||
// Pause / Resume
|
||||
// Pause / Resume (supports multi-selection)
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(torrent) = app.selected_torrent() {
|
||||
if !app.selected_hashes.is_empty() {
|
||||
let hashes: Vec<String> = app.selected_hashes.iter().cloned().collect();
|
||||
app.status_message = Some(format!("Pausing {} torrent(s)...", hashes.len()));
|
||||
let _ = cmd_tx.send(Command::Pause(hashes)).await;
|
||||
} else if let Some(torrent) = app.selected_torrent() {
|
||||
let hash = torrent.hash.clone();
|
||||
if hash.is_empty() {
|
||||
app.status_message = Some("Error: selected torrent has empty hash".to_string());
|
||||
return Ok(());
|
||||
}
|
||||
let is_paused = torrent.status == "pausedDL"
|
||||
|| torrent.status == "pausedUP"
|
||||
|| torrent.status == "stoppedDL"
|
||||
|
|
@ -384,21 +531,21 @@ async fn handle_input(
|
|||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
// Delete (supports multi-selection)
|
||||
KeyCode::Char('x') => {
|
||||
if app.selected_torrent_hash().is_some() {
|
||||
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
||||
app.show_delete_confirm = true;
|
||||
app.delete_with_files = false;
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if app.selected_torrent_hash().is_some() {
|
||||
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
||||
app.show_delete_confirm = true;
|
||||
app.delete_with_files = false;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
if app.selected_torrent_hash().is_some() {
|
||||
if !app.selected_hashes.is_empty() || app.selected_torrent_hash().is_some() {
|
||||
app.show_delete_confirm = true;
|
||||
app.delete_with_files = true;
|
||||
}
|
||||
|
|
|
|||
110
src/network.rs
110
src/network.rs
|
|
@ -153,6 +153,16 @@ pub fn spawn_network_worker(
|
|||
}
|
||||
}
|
||||
}
|
||||
Command::AddTorrentAdvanced { urls, save_path, category, tags, paused, skip_checking } => {
|
||||
match client.add_torrents_advanced(&urls, &save_path, &category, &tags, paused, skip_checking).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) added with options".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::AddFile(path) => {
|
||||
match client.add_torrent_file(&path).await {
|
||||
Ok(()) => {
|
||||
|
|
@ -216,6 +226,106 @@ pub fn spawn_network_worker(
|
|||
}
|
||||
}
|
||||
}
|
||||
Command::Recheck(hashes) => {
|
||||
match client.recheck_torrents(&hashes).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) rechecked".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Relocate { hashes, new_path } => {
|
||||
match client.relocate_torrents(&hashes, &new_path).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Torrent(s) relocated".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::SetTorrentLimits { hashes, ul_limit, dl_limit } => {
|
||||
match client.set_torrent_limits(&hashes, dl_limit, ul_limit).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Limits updated".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::SetCategory { hashes, category } => {
|
||||
match client.set_category(&hashes, &category).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category set".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::CreateCategory { name, save_path } => {
|
||||
match client.create_category(&name, &save_path).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category created".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::DeleteCategory(name) => {
|
||||
match client.delete_categories(&[name]).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Category deleted".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::AddTags { hashes, tags } => {
|
||||
match client.add_tags(&hashes, &tags).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tags added".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::CreateTag(tag) => {
|
||||
match client.create_tags(&[tag]).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tag created".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::DeleteTag(tag) => {
|
||||
match client.delete_tags(&[tag]).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("Tag deleted".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::SetFilePriority { hash, file_ids, priority } => {
|
||||
match client.set_file_priority(&hash, &file_ids, priority).await {
|
||||
Ok(()) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Ok("File priority updated".to_string()))).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx.send(NetworkEvent::ActionResult(Err(e.to_string()))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Shutdown => break,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,46 @@ impl QbittorrentClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Add torrent(s) with advanced options.
|
||||
pub async fn add_torrents_advanced(
|
||||
&self,
|
||||
urls: &str,
|
||||
save_path: &str,
|
||||
category: &str,
|
||||
tags: &[String],
|
||||
paused: bool,
|
||||
skip_checking: bool,
|
||||
) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/add", self.base_url);
|
||||
let mut params = vec![
|
||||
("urls", urls.to_string()),
|
||||
];
|
||||
if !save_path.is_empty() {
|
||||
params.push(("savepath", save_path.to_string()));
|
||||
}
|
||||
if !category.is_empty() {
|
||||
params.push(("category", category.to_string()));
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
params.push(("tags", tags.join(",")));
|
||||
}
|
||||
if paused {
|
||||
params.push(("paused", "true".to_string()));
|
||||
}
|
||||
if skip_checking {
|
||||
params.push(("skip_checking", "true".to_string()));
|
||||
}
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a torrent from a local .torrent file.
|
||||
pub async fn add_torrent_file(&self, file_path: &str) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
|
|
@ -358,6 +398,169 @@ impl QbittorrentClient {
|
|||
Ok(peers)
|
||||
}
|
||||
|
||||
/// Recheck torrents by hash.
|
||||
pub async fn recheck_torrents(&self, hashes: &[String]) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/recheck", self.base_url);
|
||||
let hashes_str = hashes.join("|");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Relocate torrents to a new path.
|
||||
pub async fn relocate_torrents(&self, hashes: &[String], new_path: &str) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/setLocation", self.base_url);
|
||||
let hashes_str = hashes.join("|");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str), ("location", new_path.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set upload/download limits for torrents (bytes/s, -1 = unlimited).
|
||||
pub async fn set_torrent_limits(&self, hashes: &[String], dl_limit: i64, ul_limit: i64) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let hashes_str = hashes.join("|");
|
||||
if dl_limit >= 0 {
|
||||
let url = format!("{}/api/v2/torrents/setDownloadLimit", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str.clone()), ("limit", dl_limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
}
|
||||
if ul_limit >= 0 {
|
||||
let url = format!("{}/api/v2/torrents/setUploadLimit", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str), ("limit", ul_limit.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set category for torrents.
|
||||
pub async fn set_category(&self, hashes: &[String], category: &str) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/setCategory", self.base_url);
|
||||
let hashes_str = hashes.join("|");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str), ("category", category.to_string())])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new category.
|
||||
pub async fn create_category(&self, name: &str, save_path: &str) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/createCategory", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("category", name), ("savePath", save_path)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete categories.
|
||||
pub async fn delete_categories(&self, names: &[String]) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/removeCategories", self.base_url);
|
||||
let categories = names.join("\n");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("categories", categories)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add tags to torrents.
|
||||
pub async fn add_tags(&self, hashes: &[String], tags: &[String]) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/addTags", self.base_url);
|
||||
let hashes_str = hashes.join("|");
|
||||
let tags_str = tags.join(",");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hashes", hashes_str), ("tags", tags_str)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create new tags.
|
||||
pub async fn create_tags(&self, tags: &[String]) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/createTags", self.base_url);
|
||||
let tags_str = tags.join(",");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("tags", tags_str)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete tags.
|
||||
pub async fn delete_tags(&self, tags: &[String]) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/deleteTags", self.base_url);
|
||||
let tags_str = tags.join(",");
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("tags", tags_str)])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set file priority for a torrent.
|
||||
pub async fn set_file_priority(&self, hash: &str, file_ids: &[i64], priority: i64) -> Result<()> {
|
||||
self.require_auth()?;
|
||||
let url = format!("{}/api/v2/torrents/filePrio", self.base_url);
|
||||
let ids = file_ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",");
|
||||
let prio = priority.to_string();
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.form(&[("hash", hash), ("id", ids.as_str()), ("priority", prio.as_str())])
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_auth(&self) -> Result<()> {
|
||||
if !self.is_authenticated {
|
||||
anyhow::bail!("Not authenticated")
|
||||
|
|
|
|||
132
src/ui.rs
132
src/ui.rs
|
|
@ -9,7 +9,7 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter};
|
||||
use crate::app::{AddModalFocus, App, DetailsTab, Focus, LoginFocus, Screen, SidebarFilter};
|
||||
use crate::logo;
|
||||
|
||||
fn format_eta(seconds: i64) -> String {
|
||||
|
|
@ -67,7 +67,7 @@ fn focus_border(focus: Focus, current: Focus) -> Style {
|
|||
/// Render the entire UI into the given frame.
|
||||
/// If the add-modal is active, `cursor_pos` is filled with the screen coordinates
|
||||
/// where the text cursor should be drawn.
|
||||
pub fn draw(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) {
|
||||
pub fn draw(f: &mut Frame, app: &mut App, cursor_pos: &mut Option<(u16, u16)>) {
|
||||
if app.screen == Screen::Login {
|
||||
draw_login_screen(f, app, cursor_pos);
|
||||
return;
|
||||
|
|
@ -134,7 +134,7 @@ fn draw_header(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|||
f.render_widget(free, cols[2]);
|
||||
}
|
||||
|
||||
fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
fn draw_body(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
|
||||
let body_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
|
||||
|
|
@ -142,17 +142,49 @@ fn draw_body(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|||
|
||||
draw_sidebar(f, app, body_chunks[0]);
|
||||
|
||||
let right_area = body_chunks[1];
|
||||
|
||||
if app.show_search {
|
||||
let search_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(5)])
|
||||
.split(right_area);
|
||||
draw_search_bar(f, app, search_chunks[0]);
|
||||
|
||||
if app.show_details {
|
||||
let detail_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(search_chunks[1]);
|
||||
draw_torrent_list(f, app, detail_chunks[0]);
|
||||
draw_details_panel(f, app, detail_chunks[1]);
|
||||
} else {
|
||||
draw_torrent_list(f, app, search_chunks[1]);
|
||||
}
|
||||
} else {
|
||||
if app.show_details {
|
||||
let right_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
|
||||
.split(body_chunks[1]);
|
||||
.split(right_area);
|
||||
draw_torrent_list(f, app, right_chunks[0]);
|
||||
draw_details_panel(f, app, right_chunks[1]);
|
||||
} else {
|
||||
draw_torrent_list(f, app, body_chunks[1]);
|
||||
draw_torrent_list(f, app, right_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_search_bar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Search ")
|
||||
.border_style(Style::default().fg(Color::Green));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let text = Paragraph::new(app.search_input.value());
|
||||
f.render_widget(text, inner);
|
||||
}
|
||||
|
||||
fn draw_sidebar(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
|
|
@ -241,10 +273,19 @@ fn draw_torrent_list(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|||
Cell::from(popularity),
|
||||
];
|
||||
|
||||
let is_selected = app.selected_hashes.contains(&t.hash);
|
||||
let style = if i == app.torrent_selected {
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.bg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
} else if is_selected {
|
||||
Style::default().bg(Color::Blue)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
|
@ -551,19 +592,28 @@ fn draw_details_peers(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|||
fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let status = app.status_message.as_deref().unwrap_or("Ready");
|
||||
|
||||
let (help_top, help_bottom) = if app.show_delete_confirm {
|
||||
("y: Confirm | n: Cancel", "")
|
||||
let selected_count = app.selected_hashes.len();
|
||||
let selection_hint = if selected_count > 0 {
|
||||
format!(" [{} selected]", selected_count)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let (help_top, help_bottom): (String, String) = if app.show_delete_confirm {
|
||||
("y: Confirm | n: Cancel".to_string(), "".to_string())
|
||||
} else if app.show_add_modal {
|
||||
("Enter: Submit | Esc: Cancel", "")
|
||||
("Enter: Submit | Esc: Cancel".to_string(), "".to_string())
|
||||
} else if app.show_search {
|
||||
("Enter: Close search | Esc: Clear".to_string(), "".to_string())
|
||||
} else if app.show_details {
|
||||
(
|
||||
"q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet",
|
||||
"1:Général 2:Trackers 3:Fichiers 4:Peers [:Prev ]:Next",
|
||||
format!("q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet{}", selection_hint),
|
||||
"1:Général 2:Trackers 3:Fichiers 4:Peers s:Select S:ClearSel .:Sort ,:Dir /:Search".to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet",
|
||||
"h/l:Change Focus",
|
||||
format!("q:Quit Tab:Focus ↑↓:Nav p:Pause x:Delete i:Details L:Speed a:Add m:Magnet{}", selection_hint),
|
||||
"h/l:ChangeFocus s:Select S:ClearSel .:Sort ,:Dir /:Search".to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -586,24 +636,24 @@ fn draw_footer(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
|||
f.render_widget(status_par, rows[0]);
|
||||
|
||||
if help_bottom.is_empty() {
|
||||
let help_par = Paragraph::new(help_top).alignment(Alignment::Left);
|
||||
let help_par = Paragraph::new(help_top.as_str()).alignment(Alignment::Left);
|
||||
f.render_widget(help_par, rows[1]);
|
||||
} else {
|
||||
let help_cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(rows[1]);
|
||||
let help_par = Paragraph::new(help_top).alignment(Alignment::Left);
|
||||
let help_par = Paragraph::new(help_top.as_str()).alignment(Alignment::Left);
|
||||
f.render_widget(help_par, help_cols[0]);
|
||||
let help2_par = Paragraph::new(help_bottom).alignment(Alignment::Right);
|
||||
let help2_par = Paragraph::new(help_bottom.as_str()).alignment(Alignment::Right);
|
||||
f.render_widget(help2_par, help_cols[1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>) {
|
||||
let area = centered_rect(60, 20, f.size());
|
||||
let area = centered_rect(70, 40, f.size());
|
||||
let block = Block::default()
|
||||
.title(" Add Magnet / URL(s) ")
|
||||
.title(" Add Torrent — URLs / Path / Category / Tags ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Green));
|
||||
|
||||
|
|
@ -611,14 +661,52 @@ fn draw_add_modal(f: &mut Frame, app: &App, cursor_pos: &mut Option<(u16, u16)>)
|
|||
f.render_widget(Clear, area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let text = Paragraph::new(app.add_input.value()).wrap(Wrap { trim: true });
|
||||
f.render_widget(text, inner);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Position for the physical cursor
|
||||
let x = inner.x + app.add_input.visual_cursor() as u16;
|
||||
let y = inner.y;
|
||||
let fields = [
|
||||
(AddModalFocus::Url, " URLs ", app.add_input.value()),
|
||||
(AddModalFocus::Path, " Save Path ", app.add_path_input.value()),
|
||||
(AddModalFocus::Category, " Category ", app.add_category_input.value()),
|
||||
(AddModalFocus::Tags, " Tags (comma separated) ", app.add_tags_input.value()),
|
||||
];
|
||||
|
||||
for (i, (focus, label, value)) in fields.iter().enumerate() {
|
||||
let focused = app.add_modal_focus == *focus;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let input_block = Block::default()
|
||||
.title(*label)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style);
|
||||
let input_inner = input_block.inner(chunks[i]);
|
||||
f.render_widget(input_block, chunks[i]);
|
||||
let text = Paragraph::new(*value);
|
||||
f.render_widget(text, input_inner);
|
||||
|
||||
if focused {
|
||||
let x = input_inner.x + match *focus {
|
||||
AddModalFocus::Url => app.add_input.visual_cursor() as u16,
|
||||
AddModalFocus::Path => app.add_path_input.visual_cursor() as u16,
|
||||
AddModalFocus::Category => app.add_category_input.visual_cursor() as u16,
|
||||
AddModalFocus::Tags => app.add_tags_input.visual_cursor() as u16,
|
||||
};
|
||||
let y = input_inner.y;
|
||||
*cursor_pos = Some((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_delete_confirm(f: &mut Frame, app: &App) {
|
||||
let block = Block::default()
|
||||
|
|
|
|||
Loading…
Reference in a new issue