From eba2eefc5ef896437ee44a7fa5ee4e38725f0c2c Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Thu, 1 Jan 2026 04:42:15 +0200 Subject: [PATCH] feat: add configurable custom tabs with user-defined columns --- config/default.toml | 21 ++++++++++ src/app/mod.rs | 3 +- src/app/tab.rs | 80 +++++++++--------------------------- src/config/mod.rs | 36 ++++++++++++++++- src/config/tabs.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 4 +- 6 files changed, 177 insertions(+), 65 deletions(-) create mode 100644 src/config/tabs.rs diff --git a/config/default.toml b/config/default.toml index 996bb5a..cc7286d 100644 --- a/config/default.toml +++ b/config/default.toml @@ -26,3 +26,24 @@ error_foreground = "red" traxor = "info" ratatui = "warn" transmission_rpc = "warn" + +# Custom tabs configuration +# Each [[tabs]] entry defines a tab with a name and list of columns. +# Available columns: +# name, status, size, downloaded, uploaded, ratio, progress, eta, +# peers, seeds, leeches, downspeed, upspeed, path, added, done, +# left, queue, error, labels, tracker, hash, private, stalled, +# finished, files, activity +# +# Example: +# [[tabs]] +# name = "All" +# columns = ["status", "ratio", "size", "uploaded", "path", "name"] +# +# [[tabs]] +# name = "Active" +# columns = ["progress", "downspeed", "upspeed", "eta", "name"] +# +# [[tabs]] +# name = "Downloading" +# columns = ["size", "left", "progress", "downspeed", "eta", "name"] diff --git a/src/app/mod.rs b/src/app/mod.rs index 7d3e778..a6a88ab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -47,9 +47,10 @@ impl App { /// /// TODO: add error types pub fn new(config: Config) -> Result { + let tabs = config.tabs.iter().cloned().map(Tab::new).collect(); Ok(Self { running: true, - tabs: vec![Tab::All, Tab::Active, Tab::Downloading], + tabs, index: 0, state: TableState::default(), torrents: Torrents::new()?, diff --git a/src/app/tab.rs b/src/app/tab.rs index eb523fa..b212a3c 100644 --- a/src/app/tab.rs +++ b/src/app/tab.rs @@ -1,79 +1,37 @@ +use crate::config::tabs::TabConfig; use std::fmt::Display; use transmission_rpc::types::TorrentGetField; -/// Available tabs. -#[derive(Debug, Clone, Default)] -pub enum Tab { - #[default] - All, - Active, - Downloading, +/// A tab with name and column configuration. +#[derive(Debug, Clone)] +pub struct Tab { + config: TabConfig, + fields: Vec, } impl Tab { - /// Returns slice [`TorrentGetField`] apropriate variants. + /// Create a new tab from config. #[must_use] - pub const fn fields(&self) -> &[TorrentGetField] { - match self { - Self::All => &[ - TorrentGetField::Status, - TorrentGetField::PeersGettingFromUs, - TorrentGetField::UploadRatio, - TorrentGetField::TotalSize, - TorrentGetField::UploadedEver, - TorrentGetField::DownloadDir, - TorrentGetField::Name, - ], - Self::Active => &[ - TorrentGetField::TotalSize, - TorrentGetField::UploadedEver, - TorrentGetField::UploadRatio, - TorrentGetField::PeersGettingFromUs, - TorrentGetField::PeersSendingToUs, - TorrentGetField::Status, - TorrentGetField::Eta, - TorrentGetField::PercentDone, - TorrentGetField::RateDownload, - TorrentGetField::RateUpload, - TorrentGetField::Name, - ], - Self::Downloading => &[ - TorrentGetField::TotalSize, - TorrentGetField::LeftUntilDone, - TorrentGetField::PercentDone, - TorrentGetField::RateDownload, - TorrentGetField::Eta, - TorrentGetField::DownloadDir, - TorrentGetField::Name, - ], - } + pub fn new(config: TabConfig) -> Self { + let fields = config.fields(); + Self { config, fields } } -} -impl From for Tab { - fn from(value: usize) -> Self { - #[allow(clippy::match_same_arms)] - match value { - 0 => Self::All, - 1 => Self::Active, - 2 => Self::Downloading, - _ => Self::All, - } + /// Returns the column fields for this tab. + #[must_use] + pub fn fields(&self) -> &[TorrentGetField] { + &self.fields } -} -impl AsRef for Tab { - fn as_ref(&self) -> &str { - match self { - Self::All => "All", - Self::Active => "Active", - Self::Downloading => "Downloading", - } + /// Returns the tab name. + #[must_use] + pub fn name(&self) -> &str { + &self.config.name } } impl Display for Tab { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_ref()) + write!(f, "{}", self.config.name) } } diff --git a/src/config/mod.rs b/src/config/mod.rs index d025f35..9653520 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ pub mod color; pub mod keybinds; pub mod log; +pub mod tabs; use color::ColorConfig; use color_eyre::{ @@ -11,10 +12,12 @@ use filecaster::FromFile; use keybinds::KeybindsConfig; use log::LogConfig; use merge::Merge; +use serde::Deserialize; use std::{ fs::read_to_string, path::{Path, PathBuf}, }; +use tabs::TabConfig; use tracing::{debug, info, warn}; const DEFAULT_CONFIG_STR: &str = include_str!("../../config/default.toml"); @@ -24,6 +27,15 @@ pub struct Config { pub keybinds: KeybindsConfig, pub colors: ColorConfig, pub log: LogConfig, + #[from_file(skip)] + pub tabs: Vec, +} + +/// Helper struct for parsing tabs from TOML. +#[derive(Debug, Deserialize, Default)] +struct TabsFile { + #[serde(default)] + tabs: Vec, } impl Config { @@ -50,12 +62,34 @@ impl Config { ("user-specific", get_config_path()?), ]; + let mut tabs: Option> = None; + for (label, path) in &candidates { merge_config(&mut cfg_file, label, path)?; + // Load tabs separately (last one wins) + if let Some(t) = load_tabs(path) { + tabs = Some(t); + } } + let mut config: Self = cfg_file.into(); + config.tabs = tabs.unwrap_or_else(tabs::default_tabs); + debug!("Configuration loaded successfully."); - Ok(cfg_file.into()) + Ok(config) + } +} + +fn load_tabs(path: &Path) -> Option> { + if !path.exists() { + return None; + } + let content = read_to_string(path).ok()?; + let tabs_file: TabsFile = toml::from_str(&content).ok()?; + if tabs_file.tabs.is_empty() { + None + } else { + Some(tabs_file.tabs) } } diff --git a/src/config/tabs.rs b/src/config/tabs.rs new file mode 100644 index 0000000..83fc256 --- /dev/null +++ b/src/config/tabs.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; +use transmission_rpc::types::TorrentGetField; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TabConfig { + pub name: String, + pub columns: Vec, +} + +impl TabConfig { + /// Parse column strings into `TorrentGetField` variants. + #[must_use] + pub fn fields(&self) -> Vec { + self.columns.iter().filter_map(|s| parse_field(s)).collect() + } +} + +fn parse_field(s: &str) -> Option { + // Match against known field names (case-insensitive) + Some(match s.to_lowercase().as_str() { + "name" => TorrentGetField::Name, + "status" => TorrentGetField::Status, + "size" | "totalsize" | "total_size" => TorrentGetField::TotalSize, + "downloaded" | "downloadedever" | "downloaded_ever" => TorrentGetField::DownloadedEver, + "uploaded" | "uploadedever" | "uploaded_ever" => TorrentGetField::UploadedEver, + "ratio" | "uploadratio" | "upload_ratio" => TorrentGetField::UploadRatio, + "progress" | "percent" | "percentdone" | "percent_done" => TorrentGetField::PercentDone, + "eta" => TorrentGetField::Eta, + "peers" | "peersconnected" | "peers_connected" => TorrentGetField::PeersConnected, + "seeds" | "peerssending" | "peers_sending" => TorrentGetField::PeersSendingToUs, + "leeches" | "peersgetting" | "peers_getting" => TorrentGetField::PeersGettingFromUs, + "downspeed" | "ratedownload" | "rate_download" => TorrentGetField::RateDownload, + "upspeed" | "rateupload" | "rate_upload" => TorrentGetField::RateUpload, + "path" | "downloaddir" | "download_dir" => TorrentGetField::DownloadDir, + "added" | "addeddate" | "added_date" => TorrentGetField::AddedDate, + "done" | "donedate" | "done_date" => TorrentGetField::DoneDate, + "left" | "leftuntildone" | "left_until_done" => TorrentGetField::LeftUntilDone, + "queue" | "queueposition" | "queue_position" => TorrentGetField::QueuePosition, + "error" => TorrentGetField::Error, + "errorstring" | "error_string" => TorrentGetField::ErrorString, + "labels" => TorrentGetField::Labels, + "tracker" | "trackerlist" | "tracker_list" => TorrentGetField::TrackerList, + "hash" | "hashstring" | "hash_string" => TorrentGetField::HashString, + "private" | "isprivate" | "is_private" => TorrentGetField::IsPrivate, + "stalled" | "isstalled" | "is_stalled" => TorrentGetField::IsStalled, + "finished" | "isfinished" | "is_finished" => TorrentGetField::IsFinished, + "files" | "filecount" | "file_count" => TorrentGetField::FileCount, + "activity" | "activitydate" | "activity_date" => TorrentGetField::ActivityDate, + _ => return None, + }) +} + +/// Default tabs matching original hardcoded behavior. +#[must_use] +pub fn default_tabs() -> Vec { + vec![ + TabConfig { + name: "All".into(), + columns: vec![ + "status".into(), + "peers_getting".into(), + "ratio".into(), + "size".into(), + "uploaded".into(), + "path".into(), + "name".into(), + ], + }, + TabConfig { + name: "Active".into(), + columns: vec![ + "size".into(), + "uploaded".into(), + "ratio".into(), + "peers_getting".into(), + "seeds".into(), + "status".into(), + "eta".into(), + "progress".into(), + "downspeed".into(), + "upspeed".into(), + "name".into(), + ], + }, + TabConfig { + name: "Downloading".into(), + columns: vec![ + "size".into(), + "left".into(), + "progress".into(), + "downspeed".into(), + "eta".into(), + "path".into(), + "name".into(), + ], + }, + ] +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a4f8475..031ad39 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,7 +4,7 @@ mod status; mod table; use crate::{ - app::{App, InputMode, Tab}, + app::{App, InputMode}, config::color::ColorConfig, }; use help::render_help; @@ -55,7 +55,7 @@ pub fn render(app: &mut App, frame: &mut Frame) { let selected = &app.torrents.selected; let colors = &app.config.colors; - let table = build_table(torrents, selected, colors, Tab::from(app.index()).fields()); + let table = build_table(torrents, selected, colors, app.tabs()[app.index()].fields()); frame.render_stateful_widget(table, chunks[1], &mut app.state);