refactor(clippy): fix clippy warning

This commit is contained in:
Kristofers Solo 2025-07-10 19:54:48 +03:00
parent b563c7ea24
commit ae0fc2bbf5
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
25 changed files with 499 additions and 295 deletions

View File

@ -24,3 +24,8 @@ tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
transmission-rpc = "0.5"
url = "2.5"
[lints.clippy]
pedantic = "warn"
nursery = "warn"
unwrap_used = "warn"

View File

@ -18,7 +18,7 @@ move = "m"
[colors]
highlight_background = "magenta"
highlight_foreground = "black"
warning_foreground = "yellow"
header_foreground = "yellow"
info_foreground = "blue"
error_foreground = "red"

View File

@ -1,6 +1,6 @@
use derive_more::Display;
#[derive(Debug, Clone, PartialEq, Display)]
#[derive(Debug, Clone, PartialEq, Eq, Display)]
pub enum Action {
#[display("Quit")]
Quit,

View File

@ -1,15 +1,18 @@
use super::{types::Selected, Torrents};
use color_eyre::{eyre::eyre, Result};
use super::{Torrents, types::Selected};
use color_eyre::{Result, eyre::eyre};
use std::{collections::HashSet, path::Path};
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
impl Torrents {
/// # Errors
///
/// TODO: add error types
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
let ids: HashSet<_> = ids.into();
let torrents_to_toggle: Vec<_> = self
.torrents
.iter()
.filter(|torrent| torrent.id.map_or(false, |id| ids.contains(&id)))
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
.collect();
for torrent in torrents_to_toggle {
@ -27,6 +30,9 @@ impl Torrents {
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn toggle_all(&mut self) -> Result<()> {
let torrents_to_toggle: Vec<_> = self
.torrents
@ -53,14 +59,23 @@ impl Torrents {
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn start_all(&mut self) -> Result<()> {
self.action_all(TorrentAction::StartNow).await
}
/// # Errors
///
/// TODO: add error types
pub async fn stop_all(&mut self) -> Result<()> {
self.action_all(TorrentAction::Stop).await
}
/// # Errors
///
/// TODO: add error types
pub async fn move_dir(
&mut self,
torrent: &Torrent,
@ -76,6 +91,9 @@ impl Torrents {
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
self.client
.torrent_remove(ids.into(), delete_local_data)
@ -84,6 +102,9 @@ impl Torrents {
Ok(())
}
/// # Errors
///
/// TODO: add error types
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
@ -94,11 +115,14 @@ impl Torrents {
Ok(())
}
/// # Errors
///
/// TODO: add error types
async fn action_all(&mut self, action: TorrentAction) -> Result<()> {
let ids = self
.torrents
.iter()
.filter_map(|torrent| torrent.id())
.filter_map(Torrent::id)
.collect::<Vec<_>>();
self.client

View File

@ -30,9 +30,13 @@ pub struct App<'a> {
pub completion_idx: usize,
}
impl<'a> App<'a> {
impl App<'_> {
/// Constructs a new instance of [`App`].
/// Returns instance of `Self`.
///
/// # Errors
///
/// TODO: add error types
pub fn new(config: Config) -> Result<Self> {
Ok(Self {
running: true,
@ -50,6 +54,9 @@ impl<'a> App<'a> {
})
}
/// # Errors
///
/// TODO: add error types
pub async fn complete_input(&mut self) -> Result<()> {
let path = PathBuf::from(&self.input);
let (base_path, partial_name) = split_path_components(path);
@ -62,13 +69,18 @@ impl<'a> App<'a> {
}
/// Handles the tick event of the terminal.
///
/// # Errors
///
/// TODO: add error types
pub async fn tick(&mut self) -> Result<()> {
self.torrents.update().await?;
Ok(())
}
/// Set running to false to quit the application.
pub fn quit(&mut self) {
#[inline]
pub const fn quit(&mut self) {
self.running = false;
}
@ -103,13 +115,15 @@ impl<'a> App<'a> {
}
/// Switches to the next tab.
pub fn next_tab(&mut self) {
#[inline]
pub const fn next_tab(&mut self) {
self.close_help();
self.index = (self.index + 1) % self.tabs.len();
}
/// Switches to the previous tab.
pub fn prev_tab(&mut self) {
#[inline]
pub const fn prev_tab(&mut self) {
self.close_help();
if self.index > 0 {
self.index -= 1;
@ -119,33 +133,44 @@ impl<'a> App<'a> {
}
/// Switches to the tab whose index is `idx`.
pub fn switch_tab(&mut self, idx: usize) {
#[inline]
pub const fn switch_tab(&mut self, idx: usize) {
self.close_help();
self.index = idx
self.index = idx;
}
/// Returns current active [`Tab`] number
pub fn index(&self) -> usize {
#[inline]
#[must_use]
pub const fn index(&self) -> usize {
self.index
}
/// Returns [`Tab`] slice
pub fn tabs(&self) -> &[Tab] {
#[inline]
#[must_use]
pub const fn tabs(&self) -> &[Tab] {
self.tabs
}
pub fn toggle_help(&mut self) {
#[inline]
pub const fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn close_help(&mut self) {
#[inline]
pub const fn close_help(&mut self) {
self.show_help = false;
}
pub fn open_help(&mut self) {
#[inline]
pub const fn open_help(&mut self) {
self.show_help = true;
}
/// # Errors
///
/// TODO: add error types
pub async fn toggle_torrents(&mut self) -> Result<()> {
let ids = self.selected(false);
self.torrents.toggle(ids).await?;
@ -153,6 +178,9 @@ impl<'a> App<'a> {
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn delete(&mut self, delete_local_data: bool) -> Result<()> {
let ids = self.selected(false);
self.torrents.delete(ids, delete_local_data).await?;
@ -160,6 +188,9 @@ impl<'a> App<'a> {
Ok(())
}
/// # Errors
///
/// TODO: add error types
pub async fn move_torrent(&mut self) -> Result<()> {
self.torrents.move_selection(&self.input).await?;
self.input.clear();
@ -170,8 +201,7 @@ impl<'a> App<'a> {
pub fn prepare_move_action(&mut self) {
if let Some(download_dir) = self.get_current_downlaod_dir() {
let path_buf = PathBuf::from(download_dir);
self.update_cursor(path_buf);
self.update_cursor(&download_dir);
}
self.input_mode = true;
}
@ -237,11 +267,11 @@ impl<'a> App<'a> {
.find(|&t| t.id == Some(current_id))
.and_then(|t| t.download_dir.as_ref())
.map(PathBuf::from),
_ => None,
Selected::List(_) => None,
}
}
fn update_cursor(&mut self, path: PathBuf) {
fn update_cursor(&mut self, path: &Path) {
self.input = path.to_string_lossy().to_string();
self.cursor_position = self.input.len();
}

View File

@ -1,3 +1,4 @@
use std::fmt::Display;
use transmission_rpc::types::TorrentGetField;
/// Available tabs.
@ -11,9 +12,10 @@ pub enum Tab {
impl Tab {
/// Returns slice [`TorrentGetField`] apropriate variants.
pub fn fields(&self) -> &[TorrentGetField] {
#[must_use]
pub const fn fields(&self) -> &[TorrentGetField] {
match self {
Tab::All => &[
Self::All => &[
TorrentGetField::Status,
TorrentGetField::PeersGettingFromUs,
TorrentGetField::UploadRatio,
@ -22,7 +24,7 @@ impl Tab {
TorrentGetField::DownloadDir,
TorrentGetField::Name,
],
Tab::Active => &[
Self::Active => &[
TorrentGetField::TotalSize,
TorrentGetField::UploadedEver,
TorrentGetField::UploadRatio,
@ -35,7 +37,7 @@ impl Tab {
TorrentGetField::RateUpload,
TorrentGetField::Name,
],
Tab::Downloading => &[
Self::Downloading => &[
TorrentGetField::TotalSize,
TorrentGetField::LeftUntilDone,
TorrentGetField::PercentDone,
@ -48,18 +50,30 @@ impl Tab {
}
}
impl AsRef<str> for Tab {
fn as_ref(&self) -> &str {
match self {
Tab::All => "All",
Tab::Active => "Active",
Tab::Downloading => "Downloading",
impl From<usize> 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,
}
}
}
impl ToString for Tab {
fn to_string(&self) -> String {
self.as_ref().into()
impl AsRef<str> for Tab {
fn as_ref(&self) -> &str {
match self {
Self::All => "All",
Self::Active => "Active",
Self::Downloading => "Downloading",
}
}
}
impl Display for Tab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_ref())
}
}

View File

@ -1,8 +1,8 @@
use color_eyre::{eyre::eyre, Result};
use color_eyre::{Result, eyre::eyre};
use std::{collections::HashSet, fmt::Debug};
use transmission_rpc::{
types::{Id, Torrent, TorrentGetField},
TransClient,
types::{Id, Torrent, TorrentGetField},
};
use url::Url;
@ -17,7 +17,11 @@ pub struct Torrents {
impl Torrents {
/// Constructs a new instance of [`Torrents`].
pub fn new() -> Result<Torrents> {
///
/// # Errors
///
/// TODO: add error types
pub fn new() -> Result<Self> {
let url = Url::parse("http://localhost:9091/transmission/rpc")?;
Ok(Self {
client: TransClient::new(url),
@ -28,10 +32,19 @@ impl Torrents {
}
/// Returns the number of [`Torrent`]s in [`Torrents`]
pub fn len(&self) -> usize {
#[inline]
#[must_use]
pub const fn len(&self) -> usize {
self.torrents.len()
}
/// Returns `true` if the `torrents` contains no elements.
#[inline]
#[must_use]
pub const fn is_empty(&self) -> bool {
self.torrents.is_empty()
}
/// Sets `self.fields`
pub fn set_fields(&mut self, fields: Option<Vec<TorrentGetField>>) -> &mut Self {
self.fields = fields;
@ -39,12 +52,20 @@ impl Torrents {
}
/// Sets
///
/// # Errors
///
/// TODO: add error types
pub fn url(&mut self, url: &str) -> Result<&mut Self> {
self.client = TransClient::new(Url::parse(url)?);
Ok(self)
}
/// Updates [`Torrent`] values.
///
/// # Errors
///
/// TODO: add error types
pub async fn update(&mut self) -> Result<&mut Self> {
self.torrents = self
.client
@ -56,6 +77,9 @@ impl Torrents {
Ok(self)
}
/// # Errors
///
/// TODO: add error types
pub async fn move_selection(&mut self, location: &str) -> Result<()> {
let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect();
self.client
@ -68,10 +92,10 @@ impl Torrents {
impl Debug for Torrents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let fields: Vec<String> = match &self.fields {
Some(fields) => fields.iter().map(|field| field.to_str()).collect(),
None => vec![String::from("None")],
};
let fields = self.fields.as_ref().map_or_else(
|| vec!["None".into()],
|fields| fields.iter().map(TorrentGetField::to_str).collect(),
);
write!(
f,
"fields:

View File

@ -1,4 +1,8 @@
use std::collections::HashSet;
use std::{
collections::{HashSet, hash_set::IntoIter},
hash::BuildHasher,
iter::Once,
};
use transmission_rpc::types::Id;
#[derive(Debug, Clone, PartialEq, Eq)]
@ -7,29 +11,51 @@ pub enum Selected {
List(HashSet<i64>),
}
impl Into<HashSet<i64>> for Selected {
fn into(self) -> HashSet<i64> {
#[derive(Debug)]
pub enum SelectedIntoIter {
One(Once<i64>),
Many(IntoIter<i64>),
}
impl Iterator for SelectedIntoIter {
type Item = i64;
fn next(&mut self) -> Option<Self::Item> {
match self {
Selected::Current(id) => std::iter::once(id).collect(),
Selected::List(ids) => ids,
Self::One(it) => it.next(),
Self::Many(it) => it.next(),
}
}
}
impl Into<Vec<i64>> for Selected {
fn into(self) -> Vec<i64> {
impl IntoIterator for Selected {
type Item = i64;
type IntoIter = SelectedIntoIter;
fn into_iter(self) -> Self::IntoIter {
match self {
Selected::Current(id) => vec![id],
Selected::List(ids) => ids.into_iter().collect(),
Self::Current(id) => SelectedIntoIter::One(std::iter::once(id)),
Self::List(set) => SelectedIntoIter::Many(set.into_iter()),
}
}
}
impl Into<Vec<Id>> for Selected {
fn into(self) -> Vec<Id> {
match self {
Selected::Current(id) => vec![Id::Id(id)],
Selected::List(ids) => ids.into_iter().map(Id::Id).collect(),
}
impl<S> From<Selected> for HashSet<i64, S>
where
S: BuildHasher + Default,
{
fn from(value: Selected) -> Self {
value.into_iter().collect()
}
}
impl From<Selected> for Vec<i64> {
fn from(value: Selected) -> Self {
value.into_iter().collect()
}
}
impl From<Selected> for Vec<Id> {
fn from(value: Selected) -> Self {
value.into_iter().map(Id::Id).collect()
}
}

View File

@ -7,7 +7,9 @@ pub struct FileSize(Unit);
impl_unit_newtype!(FileSize);
impl FileSize {
pub fn new(bytes: u64) -> Self {
#[inline]
#[must_use]
pub const fn new(bytes: u64) -> Self {
Self(Unit::from_raw(bytes))
}
}

View File

@ -68,7 +68,7 @@ impl Wrapper for TorrentGetField {
Self::Peers => "Peers",
Self::PeersConnected => "Connected",
Self::PeersFrom => "Peers From",
Self::PeersGettingFromUs => "Peers",
Self::PeersGettingFromUs => "Peers Receiving",
Self::PeersSendingToUs => "Seeds",
Self::PercentComplete => "Percent Complete",
Self::PercentDone => "%",
@ -203,6 +203,7 @@ impl Wrapper for TorrentGetField {
}
fn width(&self) -> u16 {
#![allow(clippy::match_same_arms)]
match self {
Self::ActivityDate => 20,
Self::AddedDate => 20,
@ -292,8 +293,8 @@ fn format_option_string<T: Display>(value: Option<T>) -> String {
fn format_eta(value: Option<i64>) -> String {
match value {
Some(-2) => "?".into(),
None | Some(-1) | Some(..0) => String::new(),
Some(v) => format!("{} s", v),
None | Some(-1 | ..0) => String::new(),
Some(v) => format!("{v} s"),
}
}
@ -303,7 +304,7 @@ trait Formatter {
impl Formatter for Option<f32> {
fn format(&self) -> String {
self.map(|v| format!("{:.2}", v)).unwrap_or_default()
self.map(|v| format!("{v:.2}")).unwrap_or_default()
}
}

View File

@ -7,7 +7,9 @@ pub struct NetSpeed(Unit);
impl_unit_newtype!(NetSpeed);
impl NetSpeed {
pub fn new(bytes_per_second: u64) -> Self {
#[inline]
#[must_use]
pub const fn new(bytes_per_second: u64) -> Self {
Self(Unit::from_raw(bytes_per_second))
}
}

View File

@ -50,10 +50,14 @@ where
}
impl Unit {
#[inline]
#[must_use]
pub const fn from_raw(value: u64) -> Self {
Self(value)
}
#[inline]
#[must_use]
pub const fn value(&self) -> u64 {
self.0
}
@ -66,15 +70,18 @@ pub struct UnitDisplay<'a> {
}
impl<'a> UnitDisplay<'a> {
pub fn new(unit: &'a Unit, units: &'a [&'a str]) -> Self {
#[inline]
#[must_use]
pub const fn new(unit: &'a Unit, units: &'a [&'a str]) -> Self {
Self { unit, units }
}
}
impl<'a> Display for UnitDisplay<'a> {
impl Display for UnitDisplay<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
const THRESHOLD: f64 = 1024.0;
#[allow(clippy::cast_precision_loss)]
let value = self.unit.0 as f64;
if value < THRESHOLD {
@ -111,7 +118,9 @@ macro_rules! impl_unit_newtype {
}
impl $wrapper {
pub fn unit(&self) -> &Unit {
#[inline]
#[must_use]
pub const fn unit(&self) -> &Unit {
&self.0
}
}

34
src/config/color.rs Normal file
View File

@ -0,0 +1,34 @@
use derive_macro::FromFile;
use merge::{Merge, option::overwrite_none};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Merge)]
pub struct ColorConfigFile {
#[merge(strategy = overwrite_none)]
pub highlight_background: Option<String>,
#[merge(strategy = overwrite_none)]
pub highlight_foreground: Option<String>,
#[merge(strategy = overwrite_none)]
pub header_foreground: Option<String>,
#[merge(strategy = overwrite_none)]
pub info_foreground: Option<String>,
}
#[derive(Debug, Clone, FromFile)]
pub struct ColorConfig {
pub highlight_background: String,
pub highlight_foreground: String,
pub header_foreground: String,
pub info_foreground: String,
}
impl Default for ColorConfigFile {
fn default() -> Self {
Self {
highlight_background: Some("magenta".to_string()),
highlight_foreground: Some("black".to_string()),
header_foreground: Some("yellow".to_string()),
info_foreground: Some("blue".to_string()),
}
}
}

View File

@ -1,59 +0,0 @@
use derive_macro::FromFile;
use merge::{Merge, option::overwrite_none};
use ratatui::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Merge)]
pub struct ColorsConfigFile {
#[merge(strategy = overwrite_none)]
pub highlight_background: Option<String>,
#[merge(strategy = overwrite_none)]
pub highlight_foreground: Option<String>,
#[merge(strategy = overwrite_none)]
pub warning_foreground: Option<String>,
#[merge(strategy = overwrite_none)]
pub info_foreground: Option<String>,
#[merge(strategy = overwrite_none)]
pub error_foreground: Option<String>,
}
#[derive(Debug, Clone, FromFile)]
pub struct ColorsConfig {
pub highlight_background: String,
pub highlight_foreground: String,
pub warning_foreground: String,
pub info_foreground: String,
pub error_foreground: String,
}
impl ColorsConfig {
pub fn get_color(&self, color_name: &str) -> Color {
match color_name.to_lowercase().as_str() {
"black" => Color::Black,
"blue" => Color::Blue,
"cyan" => Color::Cyan,
"darkgray" => Color::DarkGray,
"gray" => Color::Gray,
"green" => Color::Green,
"lightgreen" => Color::LightGreen,
"lightred" => Color::LightRed,
"magenta" => Color::Magenta,
"red" => Color::Red,
"white" => Color::White,
"yellow" => Color::Yellow,
_ => Color::Reset, // Default to reset, if color name is not recognized
}
}
}
impl Default for ColorsConfigFile {
fn default() -> Self {
Self {
highlight_background: Some("magenta".to_string()),
highlight_foreground: Some("black".to_string()),
warning_foreground: Some("yellow".to_string()),
info_foreground: Some("blue".to_string()),
error_foreground: Some("red".to_string()),
}
}
}

View File

@ -1,14 +1,20 @@
mod colors;
mod keybinds;
mod log;
pub mod color;
pub mod keybinds;
pub mod log;
use color_eyre::Result;
use colors::{ColorsConfig, ColorsConfigFile};
use color::{ColorConfig, ColorConfigFile};
use color_eyre::{
Result,
eyre::{Context, ContextCompat, Ok},
};
use keybinds::{KeybindsConfig, KeybindsConfigFile};
use log::{LogConfig, LogConfigFile};
use merge::{Merge, option::overwrite_none};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::{
fs::read_to_string,
path::{Path, PathBuf},
};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Default, Deserialize, Serialize, Merge)]
@ -16,7 +22,7 @@ pub struct ConfigFile {
#[merge(strategy = overwrite_none)]
pub keybinds: Option<KeybindsConfigFile>,
#[merge(strategy = overwrite_none)]
pub colors: Option<ColorsConfigFile>,
pub colors: Option<ColorConfigFile>,
#[merge(strategy = overwrite_none)]
pub log: Option<LogConfigFile>,
}
@ -24,53 +30,29 @@ pub struct ConfigFile {
#[derive(Debug, Clone)]
pub struct Config {
pub keybinds: KeybindsConfig,
pub colors: ColorsConfig,
pub colors: ColorConfig,
pub log: LogConfig,
}
impl Config {
/// # Errors
///
/// TODO: add error types
#[tracing::instrument(name = "Loading configuration")]
pub fn load() -> Result<Self> {
let mut config = ConfigFile::default();
let mut cfg_file = ConfigFile::default();
// Load system-wide config
let system_config_path = PathBuf::from("/etc/xdg/traxor/config.toml");
if system_config_path.exists() {
info!("Loading system-wide config from: {:?}", system_config_path);
let config_str = std::fs::read_to_string(&system_config_path)?;
let system_config = toml::from_str::<ConfigFile>(&config_str)?;
config.merge(system_config);
info!("Successfully loaded system-wide config.");
} else {
warn!("System-wide config not found at: {:?}", system_config_path);
}
let candidates = [
("system-wide", PathBuf::from("/etc/xdg/traxor/config.toml")),
("user-specific", get_config_path()?),
];
// Load user-specific config
let user_config_path = Self::get_config_path()?;
if user_config_path.exists() {
info!("Loading user-specific config from: {:?}", user_config_path);
let config_str = std::fs::read_to_string(&user_config_path)?;
let user_config = toml::from_str::<ConfigFile>(&config_str)?;
config.merge(user_config);
info!("Successfully loaded user-specific config.");
} else {
warn!("User-specific config not found at: {:?}", user_config_path);
for (label, path) in &candidates {
merge_config(&mut cfg_file, label, path)?;
}
debug!("Configuration loaded successfully.");
Ok(config.into())
}
#[tracing::instrument(name = "Getting config path")]
fn get_config_path() -> Result<PathBuf> {
let config_dir = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| panic!("Could not find home directory"))
.join(".config")
});
Ok(config_dir.join("traxor").join("config.toml"))
Ok(cfg_file.into())
}
}
@ -83,3 +65,47 @@ impl From<ConfigFile> for Config {
}
}
}
#[tracing::instrument(name = "Getting config path")]
fn get_config_path() -> Result<PathBuf> {
let config_dir =
dirs::config_dir().context("Could not determine user configuration directory")?;
Ok(config_dir.join("traxor").join("config.toml"))
}
#[tracing::instrument(name = "Merging config", skip(cfg_file, path))]
fn merge_config(cfg_file: &mut ConfigFile, label: &str, path: &Path) -> Result<()> {
if !exists_and_log(label, path) {
return Ok(());
}
info!("Loading {} config from: {:?}", label, path);
let s = read_config_str(label, path)?;
let other = parse_config_toml(label, &s)?;
cfg_file.merge(other);
info!("Successfully loaded {} config.", label);
Ok(())
}
fn exists_and_log(label: &str, path: &Path) -> bool {
if !path.exists() {
warn!("{} config not found at: {:?}", label, path);
return false;
}
true
}
fn read_config_str(label: &str, path: &Path) -> Result<String> {
read_to_string(path).with_context(|| {
format!(
"Failed to read {label} config file at {}",
path.to_string_lossy()
)
})
}
fn parse_config_toml(label: &str, s: &str) -> Result<ConfigFile> {
toml::from_str::<ConfigFile>(s)
.with_context(|| format!("Failed to parse TOML in {label} config"))
}

View File

@ -3,9 +3,10 @@ use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use tracing::error;
/// Terminal events.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Event {
/// Terminal tick.
Tick,
@ -31,6 +32,11 @@ pub struct EventHandler {
impl EventHandler {
/// Constructs a new instance of [`EventHandler`].
///
/// # Panics
///
/// TODO: add panic
#[must_use]
pub fn new(tick_rate: u64) -> Self {
let tick_rate = Duration::from_millis(tick_rate);
let (sender, receiver) = mpsc::channel();
@ -49,12 +55,12 @@ impl EventHandler {
Ok(CrosstermEvent::Mouse(e)) => sender.send(Event::Mouse(e)),
Ok(CrosstermEvent::Resize(w, h)) => sender.send(Event::Resize(w, h)),
Err(e) => {
eprintln!("Error reading event: {:?}", e);
error!("Error reading event: {:?}", e);
break;
}
_ => Ok(()), // Ignore other events
}
.expect("failed to send terminal event")
.expect("failed to send terminal event");
}
if last_tick.elapsed() >= tick_rate {
@ -75,6 +81,10 @@ impl EventHandler {
///
/// This function will always block the current thread if
/// there is no data available and it's possible for more data to be sent.
///
/// # Errors
///
/// TODO: add error types
pub fn next(&self) -> Result<Event> {
Ok(self.receiver.recv()?)
}

View File

@ -27,6 +27,10 @@ async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<A
}
/// Handles the key events of [`App`].
///
/// # Errors
///
/// TODO: add error types
#[tracing::instrument(name = "Getting action", skip(app))]
pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<Action>> {
if app.input_mode {
@ -64,6 +68,10 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option
}
/// Handles the updates of [`App`].
///
/// # Errors
///
/// TODO: add error types
#[tracing::instrument(name = "Update", skip(app))]
pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> {
info!("updating app with action: {}", action);
@ -92,7 +100,7 @@ pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> {
Ok(())
}
/// Check if a KeyEvent matches a configured keybind string
/// Check if a [`KeyEvent`] matches a configured keybind string
fn matches_keybind(event: &KeyEvent, config_key: &str) -> bool {
let (modifiers, key_code) = parse_keybind(config_key);
let Some(key_code) = key_code else {

View File

@ -1,8 +0,0 @@
pub mod app;
pub mod config;
pub mod event;
pub mod handler;
pub mod telemetry;
pub mod tui;
pub mod ui;

View File

@ -1,15 +1,21 @@
pub mod app;
pub mod config;
pub mod event;
pub mod handler;
pub mod telemetry;
pub mod tui;
pub mod ui;
use app::App;
use color_eyre::Result;
use config::Config;
use event::{Event, EventHandler};
use handler::{get_action, update};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use telemetry::setup_logger;
use tracing::{debug, trace};
use traxor::{
app::App,
config::Config,
event::{Event, EventHandler},
handler::{get_action, update},
telemetry::setup_logger,
tui::Tui,
};
use tui::Tui;
#[tokio::main]
async fn main() -> Result<()> {

View File

@ -5,6 +5,9 @@ use tracing_appender::rolling;
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
/// # Errors
///
/// TODO: add error types
pub fn setup_logger(config: &Config) -> Result<()> {
let log_dir_path = if cfg!(debug_assertions) {
PathBuf::from(".logs")

View File

@ -4,10 +4,11 @@ use crate::ui;
use color_eyre::Result;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::Backend;
use ratatui::Terminal;
use ratatui::backend::Backend;
use std::io;
use std::panic;
use tracing::error;
/// Representation of a terminal user interface.
///
@ -23,13 +24,17 @@ pub struct Tui<B: Backend> {
impl<B: Backend> Tui<B> {
/// Constructs a new instance of [`Tui`].
pub fn new(terminal: Terminal<B>, events: EventHandler) -> Self {
pub const fn new(terminal: Terminal<B>, events: EventHandler) -> Self {
Self { terminal, events }
}
/// Initializes the terminal interface.
///
/// It enables the raw mode and sets terminal properties.
///
/// # Errors
///
/// TODO: add error types
pub fn init(&mut self) -> Result<()> {
terminal::enable_raw_mode()?;
crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
@ -39,7 +44,9 @@ impl<B: Backend> Tui<B> {
let panic_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic| {
if let Err(e) = Self::reset() {
eprintln!("Error resetting terminal: {:?}", e);
let msg = format!("Error resetting terminal: {e:?}");
eprintln!("{msg}");
error!(msg);
}
panic_hook(panic);
}));
@ -53,6 +60,10 @@ impl<B: Backend> Tui<B> {
///
/// [`Draw`]: tui::Terminal::draw
/// [`rendering`]: crate::ui:render
///
/// # Errors
///
/// TODO: add error types
pub fn draw(&mut self, app: &mut App) -> Result<()> {
self.terminal.draw(|frame| ui::render(app, frame))?;
Ok(())
@ -62,6 +73,10 @@ impl<B: Backend> Tui<B> {
///
/// This function is also used for the panic hook to revert
/// the terminal properties if unexpected errors occur.
///
/// # Errors
///
/// TODO: add error types
fn reset() -> Result<()> {
terminal::disable_raw_mode()?;
crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
@ -71,6 +86,10 @@ impl<B: Backend> Tui<B> {
/// Exits the terminal interface.
///
/// It disables the raw mode and reverts back the terminal properties.
///
/// # Errors
///
/// TODO: add error types
pub fn exit(&mut self) -> Result<()> {
Self::reset()?;
self.terminal.show_cursor()?;

View File

@ -1,5 +1,10 @@
use crate::app::App;
use ratatui::{prelude::*, widgets::*};
use ratatui::{
prelude::*,
widgets::{Block, BorderType, Borders, Cell, Clear, Row, Table},
};
use super::to_color;
pub fn render_help(frame: &mut Frame, app: &App) {
let block = Block::default()
@ -59,12 +64,7 @@ pub fn render_help(frame: &mut Frame, app: &App) {
&[Constraint::Percentage(20), Constraint::Percentage(80)],
)
.block(block)
.style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.info_foreground)),
);
.style(Style::default().fg(to_color(&app.config.colors.info_foreground)));
let area = frame.area();
let height = 15; // Desired height for the help menu

View File

@ -1,5 +1,9 @@
use crate::app::App;
use ratatui::{prelude::*, widgets::*};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Clear, Paragraph},
};
use tracing::warn;
pub fn render(f: &mut Frame, app: &mut App) {
let size = f.area();
@ -18,8 +22,17 @@ pub fn render(f: &mut Frame, app: &mut App) {
}),
);
f.set_cursor_position(ratatui::layout::Position::new(
input_area.x + app.cursor_position as u16 + 1,
let cursor_offset = u16::try_from(app.cursor_position)
.map_err(|_| {
warn!(
"cursor_position {} out of u16 range. Clamping to 0",
app.cursor_position
);
})
.unwrap_or_default();
f.set_cursor_position(Position::new(
input_area.x + cursor_offset + 1,
input_area.y + 1,
));
}

View File

@ -2,10 +2,17 @@ mod help;
mod input;
mod table;
use crate::app::{App, Tab};
use crate::{
app::{App, Tab},
config::color::ColorConfig,
};
use help::render_help;
use ratatui::{prelude::*, widgets::*};
use table::render_table;
use ratatui::{
prelude::*,
widgets::{Block, BorderType, Borders, Tabs},
};
use std::str::FromStr;
use table::build_table;
/// Renders the user interface widgets.
pub fn render(app: &mut App, frame: &mut Frame) {
@ -15,51 +22,37 @@ pub fn render(app: &mut App, frame: &mut Frame) {
// - https://github.com/ratatui-org/ratatui/tree/master/examples
let size = frame.area();
let tab_style = tab_style(&app.config.colors);
let highlighted_tab_style = highlighted_tab_style(&app.config.colors);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
.split(size);
let titles: Vec<_> = app
let titles = app
.tabs()
.iter()
.map(|x| Line::from(x.to_string()))
.collect();
.collect::<Vec<_>>();
let tabs = Tabs::new(titles)
.block(
Block::default()
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.block(default_block())
.select(app.index())
.style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.info_foreground)),
)
.highlight_style(
Style::default().fg(app
.config
.colors
.get_color(&app.config.colors.warning_foreground)),
)
.style(tab_style)
.highlight_style(highlighted_tab_style)
.divider("|");
frame.render_widget(tabs, chunks[0]); // renders tab
let table = if app.index() == 0 {
render_table(app, Tab::All)
} else if app.index() == 1 {
render_table(app, Tab::Active)
} else if app.index() == 2 {
render_table(app, Tab::Downloading)
} else {
// Fallback or handle error, though unreachable!() implies this won't happen
render_table(app, Tab::All) // Default to Tab::All if index is unexpected
};
frame.render_stateful_widget(table, chunks[1], &mut app.state); // renders table
app.torrents.set_fields(None);
let torrents = &app.torrents.torrents;
let selected = &app.torrents.selected;
let colors = &app.config.colors;
let table = build_table(torrents, selected, colors, Tab::from(app.index()).fields());
frame.render_stateful_widget(table, chunks[1], &mut app.state);
if app.show_help {
render_help(frame, app);
@ -69,3 +62,25 @@ pub fn render(app: &mut App, frame: &mut Frame) {
input::render(frame, app);
}
}
#[must_use]
pub fn to_color(value: &str) -> Color {
Color::from_str(value).unwrap_or_default()
}
fn tab_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.info_foreground);
Style::default().fg(fg)
}
fn highlighted_tab_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.header_foreground);
Style::default().fg(fg)
}
fn default_block() -> Block<'static> {
Block::default()
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
}

View File

@ -1,78 +1,78 @@
use crate::app::{App, Tab, utils::Wrapper};
use super::to_color;
use crate::{app::utils::Wrapper, config::color::ColorConfig};
use ratatui::{
layout::Constraint,
style::{Style, Styled},
widgets::{Block, BorderType, Borders, Row, Table},
};
use std::collections::HashSet;
use transmission_rpc::types::{Torrent, TorrentGetField};
pub fn render_table<'a>(app: &mut App, tab: Tab) -> Table<'a> {
let fields = tab.fields();
let selected = &app.torrents.selected.clone();
let torrents = &app.torrents.set_fields(None).torrents;
pub fn build_table<'a>(
torrents: &'a [Torrent],
selected: &HashSet<i64>,
colors: &ColorConfig,
fields: &[TorrentGetField],
) -> Table<'a> {
let row_style = row_style(colors);
let header_style = header_style(colors);
let highlight_row_style = hightlighted_row_style(colors);
let highlight_bg = app
.config
.colors
.get_color(&app.config.colors.highlight_background);
let highlight_fg = app
.config
.colors
.get_color(&app.config.colors.highlight_foreground);
let highlight_style = Style::default().bg(highlight_bg).fg(highlight_fg);
let rows: Vec<Row<'_>> = torrents
let rows = torrents
.iter()
.map(|torrent| {
Row::new(
fields
.iter()
.map(|&field| {
if let Some(id) = torrent.id {
if selected.contains(&id) {
return field.value(torrent).set_style(highlight_style);
}
}
field.value(torrent).into()
})
.collect::<Vec<_>>(),
)
})
.collect();
.map(|t| make_row(t, fields, selected, row_style))
.collect::<Vec<_>>();
let widths = fields
.iter()
.map(|&field| Constraint::Length(field.width()))
.map(|&f| Constraint::Length(f.width()))
.collect::<Vec<_>>();
let header_fg = app
.config
.colors
.get_color(&app.config.colors.warning_foreground);
let header = Row::new(
fields
.iter()
.map(|&field| field.title())
.collect::<Vec<_>>(),
)
.style(Style::default().fg(header_fg));
let row_highlight_bg = app
.config
.colors
.get_color(&app.config.colors.info_foreground);
let row_highlight_fg = app
.config
.colors
.get_color(&app.config.colors.highlight_foreground);
let row_highlight_style = Style::default().bg(row_highlight_bg).fg(row_highlight_fg);
let header = Row::new(fields.iter().map(|&field| field.title())).style(header_style);
Table::new(rows, widths)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.block(default_block())
.header(header)
.row_highlight_style(row_highlight_style)
.row_highlight_style(highlight_row_style)
.column_spacing(1)
}
fn default_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
}
fn row_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.highlight_foreground);
let bg = to_color(&cfg.info_foreground);
Style::default().bg(bg).fg(fg)
}
fn header_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.header_foreground);
Style::default().fg(fg)
}
fn hightlighted_row_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.info_foreground);
let bg = to_color(&cfg.highlight_foreground);
Style::default().bg(bg).fg(fg)
}
fn make_row<'a>(
torrent: &'a Torrent,
fields: &[TorrentGetField],
selected: &HashSet<i64>,
highlight: Style,
) -> Row<'a> {
let cells = fields.iter().map(|&field| {
if let Some(id) = torrent.id {
if selected.contains(&id) {
return field.value(torrent).set_style(highlight);
}
}
field.value(torrent).into()
});
Row::new(cells)
}