diff --git a/config/default.toml b/config/default.toml index b2bcf90..c242794 100644 --- a/config/default.toml +++ b/config/default.toml @@ -48,8 +48,8 @@ quit = "q" # ============================================================================ [colors] # UI colors -highlight_background = "magenta" -highlight_foreground = "black" +highlight_background = "#3a3a5a" +highlight_foreground = "white" header_foreground = "yellow" info_foreground = "blue" diff --git a/src/handler.rs b/src/handler.rs index 07575cf..479e190 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -45,6 +45,11 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result(key: &'a str, desc: &'a str, key_style: Style) -> Row<'a> { Row::new(vec![ Cell::from(format!(" {key}")).style(key_style), - Cell::from(desc), + Cell::from(desc).style(Style::default().fg(Color::White)), ]) } fn section_row(title: &str) -> Row<'_> { Row::new(vec![ - Cell::from(title).style(Style::default().fg(Color::DarkGray)), + Cell::from(format!(" {title}")).style( + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), ]) + .top_margin(1) } fn display_key(key: &str) -> String { diff --git a/src/ui/input.rs b/src/ui/input.rs index 46ce91e..6e4549e 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -2,13 +2,14 @@ use crate::app::{App, InputMode}; use ratatui::{ prelude::*, text::Line, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, }; use tracing::warn; pub fn render(f: &mut Frame, app: &App) { match app.input_mode { - InputMode::Move | InputMode::Rename | InputMode::Filter => render_text_input(f, app), + InputMode::Move | InputMode::Rename => render_text_input(f, app), + InputMode::Filter => render_filter_input(f, app), InputMode::ConfirmDelete(delete_local_data) => render_confirm_delete(f, delete_local_data), InputMode::None => {} } @@ -21,11 +22,13 @@ fn render_text_input(f: &mut Frame, app: &App) { let title = match app.input_mode { InputMode::Move => "Move to", InputMode::Rename => "Rename", - InputMode::Filter => "Filter", _ => return, }; - let block = Block::default().title(title).borders(Borders::ALL); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded); f.render_widget(Clear, input_area); f.render_widget(block, input_area); @@ -49,6 +52,57 @@ fn render_text_input(f: &mut Frame, app: &App) { )); } +fn render_filter_input(f: &mut Frame, app: &App) { + let size = f.area(); + let width = size.width.min(60); + let input_area = Rect::new((size.width.saturating_sub(width)) / 2, 1, width, 3); + + let filtered = app.filtered_torrents().len(); + let total = app.torrents.len(); + + let block = Block::default() + .title(" Search ") + .title_style(Style::default().fg(Color::Cyan).bold()) + .title_alignment(Alignment::Left) + .title_bottom(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{filtered}/{total}"), + Style::default().fg(Color::Yellow), + ), + Span::raw(" matches "), + ])) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)); + + f.render_widget(Clear, input_area); + f.render_widget(block, input_area); + + let prompt = Span::styled("> ", Style::default().fg(Color::Cyan).bold()); + let text = Span::raw(app.input_handler.text.as_str()); + let input = Paragraph::new(Line::from(vec![prompt, text])); + + f.render_widget( + input, + input_area.inner(Margin { + vertical: 1, + horizontal: 1, + }), + ); + + let cursor_offset = u16::try_from(app.input_handler.cursor_position).unwrap_or_else(|_| { + warn!("cursor_position out of range, clamping"); + 0 + }); + + // +2 for the "> " prompt + f.set_cursor_position(Position::new( + input_area.x + cursor_offset + 3, + input_area.y + 1, + )); +} + fn render_confirm_delete(f: &mut Frame, delete_local_data: bool) { let size = f.area(); let dialog_width = 40; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b6d939e..627dbc3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -10,6 +10,7 @@ use crate::{ use help::render_help; use ratatui::{ prelude::*, + style::Modifier, widgets::{Block, BorderType, Borders, Tabs}, }; use std::str::FromStr; @@ -82,7 +83,9 @@ fn tab_style(cfg: &ColorConfig) -> Style { fn highlighted_tab_style(cfg: &ColorConfig) -> Style { let fg = to_color(&cfg.header_foreground); - Style::default().fg(fg) + Style::default() + .fg(fg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) } fn default_block() -> Block<'static> { @@ -90,4 +93,5 @@ fn default_block() -> Block<'static> { .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)) } diff --git a/src/ui/status.rs b/src/ui/status.rs index ee1dd25..b931b91 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -8,14 +8,18 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Paragraph}, }; +#[allow(clippy::too_many_lines)] pub fn render(frame: &mut Frame, app: &App, area: Rect) { let torrents = &app.torrents.torrents; // Aggregate stats - let total_down_speed: i64 = torrents.iter().filter_map(|t| t.rate_download).sum(); - let total_up_speed: i64 = torrents.iter().filter_map(|t| t.rate_upload).sum(); - let total_downloaded: u64 = torrents.iter().filter_map(|t| t.downloaded_ever).sum(); - let total_uploaded: i64 = torrents.iter().filter_map(|t| t.uploaded_ever).sum(); + let total_down_speed = torrents.iter().filter_map(|t| t.rate_download).sum::(); + let total_up_speed = torrents.iter().filter_map(|t| t.rate_upload).sum::(); + let total_downloaded = torrents + .iter() + .filter_map(|t| t.downloaded_ever) + .sum::(); + let total_uploaded = torrents.iter().filter_map(|t| t.uploaded_ever).sum::(); let down_speed = NetSpeed::new(total_down_speed.unsigned_abs()); let up_speed = NetSpeed::new(total_up_speed.unsigned_abs()); @@ -27,13 +31,6 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let selected_count = app.torrents.selected.len(); let active_filter = app.active_filter(); - let count_info = if selected_count > 0 { - format!("{selected_count}/{total}") - } else if !active_filter.is_empty() { - format!("{filtered}/{total}") - } else { - format!("{total}") - }; let mode_text = match app.input_mode { InputMode::Move => Some("MOVE".to_string()), @@ -45,33 +42,105 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { }; let keybinds = match app.input_mode { - InputMode::None if !app.filter_text.is_empty() => "Esc Clear filter │ ? Help", - InputMode::None => "? Help", - InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel", - InputMode::Filter => "Enter Confirm │ Esc Cancel", - InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel", + InputMode::None if !app.filter_text.is_empty() => vec![ + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" Clear │ "), + Span::styled("?", Style::default().fg(Color::Yellow)), + Span::raw(" Help"), + ], + InputMode::None => vec![ + Span::styled("?", Style::default().fg(Color::Yellow)), + Span::raw(" Help │ "), + Span::styled("/", Style::default().fg(Color::Yellow)), + Span::raw(" Search"), + ], + InputMode::Move | InputMode::Rename => vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" Submit │ "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ], + InputMode::Filter => vec![ + Span::styled("Enter", Style::default().fg(Color::Yellow)), + Span::raw(" Confirm │ "), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::raw(" Cancel"), + ], + InputMode::ConfirmDelete(_) => vec![ + Span::styled("y", Style::default().fg(Color::Green)), + Span::raw(" Confirm │ "), + Span::styled("n", Style::default().fg(Color::Red)), + Span::raw(" Cancel"), + ], }; - let left = format!(" {keybinds}"); - let right = - format!("{count_info} │ ↓ {down_speed} ↑ {up_speed} │ D: {downloaded} U: {uploaded} "); - - let available_width = area.width.saturating_sub(2) as usize; - let left_len = left.chars().count(); - let right_len = right.chars().count(); - - let content = if left_len + right_len < available_width { - let padding = available_width.saturating_sub(left_len + right_len); - format!("{left}{}{right}", " ".repeat(padding)) - } else if left_len < available_width { - left + // Build right side with colored spans + let count_style = if selected_count > 0 { + Style::default().fg(Color::Magenta).bold() + } else if !active_filter.is_empty() { + Style::default().fg(Color::Cyan) } else { - right + Style::default().fg(Color::White) }; + let count_text = if selected_count > 0 { + format!("{selected_count}/{total}") + } else if !active_filter.is_empty() { + format!("{filtered}/{total}") + } else { + format!("{total}") + }; + + let down_style = if total_down_speed > 0 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + + let up_style = if total_up_speed > 0 { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let right_spans = vec![ + Span::styled(count_text, count_style), + Span::styled(" │ ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("↓{down_speed}"), down_style), + Span::raw(" "), + Span::styled(format!("↑{up_speed}"), up_style), + Span::styled(" │ ", Style::default().fg(Color::DarkGray)), + Span::styled("D:", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{downloaded} ")), + Span::styled("U:", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{uploaded} ")), + ]; + + // Calculate widths + let available_width = area.width.saturating_sub(2) as usize; + let left_len = keybinds + .iter() + .map(|s| s.content.chars().count()) + .sum::() + + 1; + let right_len = right_spans + .iter() + .map(|s| s.content.chars().count()) + .sum::(); + + let mut spans = vec![Span::raw(" ")]; + spans.extend(keybinds); + + if left_len + right_len < available_width { + let padding = available_width.saturating_sub(left_len + right_len); + spans.push(Span::raw(" ".repeat(padding))); + spans.extend(right_spans); + } + let mut block = Block::default() .borders(Borders::ALL) - .border_type(BorderType::Rounded); + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)); if let Some(ref mode) = mode_text { block = block @@ -79,7 +148,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { .title_style(Style::default().fg(Color::Yellow).bold()); } - let paragraph = Paragraph::new(Span::raw(content)).block(block); + let paragraph = Paragraph::new(Line::from(spans)).block(block); frame.render_widget(paragraph, area); } diff --git a/src/ui/table.rs b/src/ui/table.rs index e3983aa..0b0f6b1 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -2,7 +2,7 @@ use super::to_color; use crate::{app::utils::Wrapper, config::color::ColorConfig}; use ratatui::{ layout::Constraint, - style::{Style, Styled}, + style::{Color, Modifier, Style, Styled}, widgets::{Block, BorderType, Borders, Row, Table}, }; use std::collections::HashSet; @@ -14,13 +14,13 @@ pub fn build_table( colors: &ColorConfig, fields: &[TorrentGetField], ) -> Table<'static> { - let row_style = row_style(colors); + let select_style = select_style(colors); let header_style = header_style(colors); - let highlight_row_style = hightlighted_row_style(colors); + let highlight_row_style = highlighted_row_style(colors); let rows = torrents .iter() - .map(|t| make_row(t, fields, selected, row_style, colors)) + .map(|t| make_row(t, fields, selected, select_style, colors)) .collect::>(); let widths = fields @@ -28,12 +28,15 @@ pub fn build_table( .map(|&f| Constraint::Length(f.width())) .collect::>(); - let header = Row::new(fields.iter().map(|&field| field.title())).style(header_style); + let header = Row::new(fields.iter().map(|&field| field.title())) + .style(header_style) + .bottom_margin(1); Table::new(rows, widths) .block(default_block()) .header(header) .row_highlight_style(highlight_row_style) + .highlight_symbol("▶ ") .column_spacing(1) } @@ -41,23 +44,26 @@ fn default_block() -> Block<'static> { Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::DarkGray)) } -fn row_style(cfg: &ColorConfig) -> Style { +fn select_style(cfg: &ColorConfig) -> Style { let fg = to_color(&cfg.highlight_foreground); - let bg = to_color(&cfg.info_foreground); - Style::default().bg(bg).fg(fg) + let bg = to_color(&cfg.highlight_background); + Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD) } fn header_style(cfg: &ColorConfig) -> Style { let fg = to_color(&cfg.header_foreground); - Style::default().fg(fg) + Style::default() + .fg(fg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) } -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 highlighted_row_style(cfg: &ColorConfig) -> Style { + let fg = to_color(&cfg.highlight_foreground); + let bg = to_color(&cfg.highlight_background); + Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD) } fn make_row(