refactor: update UI

This commit is contained in:
Kristofers Solo 2026-01-01 16:09:23 +02:00
parent 1b145f9ace
commit d0b8f8177b
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
7 changed files with 210 additions and 62 deletions

View File

@ -48,8 +48,8 @@ quit = "q"
# ============================================================================ # ============================================================================
[colors] [colors]
# UI colors # UI colors
highlight_background = "magenta" highlight_background = "#3a3a5a"
highlight_foreground = "black" highlight_foreground = "white"
header_foreground = "yellow" header_foreground = "yellow"
info_foreground = "blue" info_foreground = "blue"

View File

@ -45,6 +45,11 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
return handle_input(key_event, app).await; return handle_input(key_event, app).await;
} }
// Close help popup with Esc
if app.show_help && key_event.code == KeyCode::Esc {
return Ok(Some(Action::ToggleHelp));
}
debug!("handling key event: {:?}", key_event); debug!("handling key event: {:?}", key_event);
let keybinds = &app.config.keybinds; let keybinds = &app.config.keybinds;

View File

@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
style::Modifier,
widgets::{Block, BorderType, Borders, Cell, Clear, Row, Table}, widgets::{Block, BorderType, Borders, Cell, Clear, Row, Table},
}; };
@ -8,18 +9,17 @@ pub fn render_help(frame: &mut Frame, app: &App) {
let kb = &app.config.keybinds; let kb = &app.config.keybinds;
let key_style = Style::default().fg(Color::Yellow).bold(); let key_style = Style::default().fg(Color::Yellow).bold();
let select_key = display_key(&kb.select); let select_key = display_key(&kb.select);
let filter_key = display_key(&kb.filter);
let rows = vec![ let rows = vec![
section_row("── Navigation ──"), section_row("Navigation"),
key_row(&kb.prev_torrent, "Move up", key_style), key_row(&kb.prev_torrent, "Move up", key_style),
key_row(&kb.next_torrent, "Move down", key_style), key_row(&kb.next_torrent, "Move down", key_style),
key_row(&kb.prev_tab, "Previous tab", key_style), key_row(&kb.prev_tab, "Previous tab", key_style),
key_row(&kb.next_tab, "Next tab", key_style), key_row(&kb.next_tab, "Next tab", key_style),
key_row(&kb.switch_tab_1, "All torrents", key_style), key_row("1-9, 0", "Switch to tab", key_style),
key_row(&kb.switch_tab_2, "Active torrents", key_style),
key_row(&kb.switch_tab_3, "Downloading", key_style),
Row::default(), Row::default(),
section_row("── Actions ──"), section_row("Actions"),
key_row(&kb.toggle_torrent, "Start/stop torrent", key_style), key_row(&kb.toggle_torrent, "Start/stop torrent", key_style),
key_row(&kb.toggle_all, "Start/stop all", key_style), key_row(&kb.toggle_all, "Start/stop all", key_style),
key_row(&select_key, "Multi-select", key_style), key_row(&select_key, "Multi-select", key_style),
@ -28,23 +28,28 @@ pub fn render_help(frame: &mut Frame, app: &App) {
key_row(&kb.delete, "Remove torrent", key_style), key_row(&kb.delete, "Remove torrent", key_style),
key_row(&kb.delete_force, "Delete with data", key_style), key_row(&kb.delete_force, "Delete with data", key_style),
Row::default(), Row::default(),
section_row("── General ──"), section_row("Search"),
key_row(&filter_key, "Search/filter", key_style),
key_row("Esc", "Clear filter", key_style),
Row::default(),
section_row("General"),
key_row(&kb.toggle_help, "Toggle help", key_style), key_row(&kb.toggle_help, "Toggle help", key_style),
key_row(&kb.quit, "Quit", key_style), key_row(&kb.quit, "Quit", key_style),
]; ];
#[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_possible_truncation)]
let height = rows.len() as u16 + 4; let height = rows.len() as u16 + 4;
let width = 40; let width = 44;
let block = Block::default() let block = Block::default()
.title(" Keybindings ") .title(" Keybindings ")
.title_style(Style::default().fg(Color::Cyan).bold())
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
let table = Table::new(rows, [Constraint::Length(16), Constraint::Fill(1)]).block(block); let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]).block(block);
let area = frame.area(); let area = frame.area();
let popup_area = Rect::new( let popup_area = Rect::new(
@ -61,14 +66,19 @@ pub fn render_help(frame: &mut Frame, app: &App) {
fn key_row<'a>(key: &'a str, desc: &'a str, key_style: Style) -> Row<'a> { fn key_row<'a>(key: &'a str, desc: &'a str, key_style: Style) -> Row<'a> {
Row::new(vec![ Row::new(vec![
Cell::from(format!(" {key}")).style(key_style), 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<'_> { fn section_row(title: &str) -> Row<'_> {
Row::new(vec![ 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 { fn display_key(key: &str) -> String {

View File

@ -2,13 +2,14 @@ use crate::app::{App, InputMode};
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
text::Line, text::Line,
widgets::{Block, Borders, Clear, Paragraph}, widgets::{Block, BorderType, Borders, Clear, Paragraph},
}; };
use tracing::warn; use tracing::warn;
pub fn render(f: &mut Frame, app: &App) { pub fn render(f: &mut Frame, app: &App) {
match app.input_mode { 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::ConfirmDelete(delete_local_data) => render_confirm_delete(f, delete_local_data),
InputMode::None => {} InputMode::None => {}
} }
@ -21,11 +22,13 @@ fn render_text_input(f: &mut Frame, app: &App) {
let title = match app.input_mode { let title = match app.input_mode {
InputMode::Move => "Move to", InputMode::Move => "Move to",
InputMode::Rename => "Rename", InputMode::Rename => "Rename",
InputMode::Filter => "Filter",
_ => return, _ => 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(Clear, input_area);
f.render_widget(block, 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) { fn render_confirm_delete(f: &mut Frame, delete_local_data: bool) {
let size = f.area(); let size = f.area();
let dialog_width = 40; let dialog_width = 40;

View File

@ -10,6 +10,7 @@ use crate::{
use help::render_help; use help::render_help;
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
style::Modifier,
widgets::{Block, BorderType, Borders, Tabs}, widgets::{Block, BorderType, Borders, Tabs},
}; };
use std::str::FromStr; use std::str::FromStr;
@ -82,7 +83,9 @@ fn tab_style(cfg: &ColorConfig) -> Style {
fn highlighted_tab_style(cfg: &ColorConfig) -> Style { fn highlighted_tab_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.header_foreground); 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> { fn default_block() -> Block<'static> {
@ -90,4 +93,5 @@ fn default_block() -> Block<'static> {
.title_alignment(Alignment::Center) .title_alignment(Alignment::Center)
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
} }

View File

@ -8,14 +8,18 @@ use ratatui::{
widgets::{Block, BorderType, Borders, Paragraph}, widgets::{Block, BorderType, Borders, Paragraph},
}; };
#[allow(clippy::too_many_lines)]
pub fn render(frame: &mut Frame, app: &App, area: Rect) { pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let torrents = &app.torrents.torrents; let torrents = &app.torrents.torrents;
// Aggregate stats // Aggregate stats
let total_down_speed: i64 = torrents.iter().filter_map(|t| t.rate_download).sum(); let total_down_speed = torrents.iter().filter_map(|t| t.rate_download).sum::<i64>();
let total_up_speed: i64 = torrents.iter().filter_map(|t| t.rate_upload).sum(); let total_up_speed = torrents.iter().filter_map(|t| t.rate_upload).sum::<i64>();
let total_downloaded: u64 = torrents.iter().filter_map(|t| t.downloaded_ever).sum(); let total_downloaded = torrents
let total_uploaded: i64 = torrents.iter().filter_map(|t| t.uploaded_ever).sum(); .iter()
.filter_map(|t| t.downloaded_ever)
.sum::<u64>();
let total_uploaded = torrents.iter().filter_map(|t| t.uploaded_ever).sum::<i64>();
let down_speed = NetSpeed::new(total_down_speed.unsigned_abs()); let down_speed = NetSpeed::new(total_down_speed.unsigned_abs());
let up_speed = NetSpeed::new(total_up_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 selected_count = app.torrents.selected.len();
let active_filter = app.active_filter(); 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 { let mode_text = match app.input_mode {
InputMode::Move => Some("MOVE".to_string()), 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 { let keybinds = match app.input_mode {
InputMode::None if !app.filter_text.is_empty() => "Esc Clear filter │ ? Help", InputMode::None if !app.filter_text.is_empty() => vec![
InputMode::None => "? Help", Span::styled("Esc", Style::default().fg(Color::Yellow)),
InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel", Span::raw(" Clear │ "),
InputMode::Filter => "Enter Confirm │ Esc Cancel", Span::styled("?", Style::default().fg(Color::Yellow)),
InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel", 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}"); // Build right side with colored spans
let right = let count_style = if selected_count > 0 {
format!("{count_info} │ ↓ {down_speed}{up_speed} │ D: {downloaded} U: {uploaded} "); Style::default().fg(Color::Magenta).bold()
} else if !active_filter.is_empty() {
let available_width = area.width.saturating_sub(2) as usize; Style::default().fg(Color::Cyan)
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
} else { } 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::<usize>()
+ 1;
let right_len = right_spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>();
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() let mut block = Block::default()
.borders(Borders::ALL) .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 { if let Some(ref mode) = mode_text {
block = block block = block
@ -79,7 +148,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
.title_style(Style::default().fg(Color::Yellow).bold()); .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); frame.render_widget(paragraph, area);
} }

View File

@ -2,7 +2,7 @@ use super::to_color;
use crate::{app::utils::Wrapper, config::color::ColorConfig}; use crate::{app::utils::Wrapper, config::color::ColorConfig};
use ratatui::{ use ratatui::{
layout::Constraint, layout::Constraint,
style::{Style, Styled}, style::{Color, Modifier, Style, Styled},
widgets::{Block, BorderType, Borders, Row, Table}, widgets::{Block, BorderType, Borders, Row, Table},
}; };
use std::collections::HashSet; use std::collections::HashSet;
@ -14,13 +14,13 @@ pub fn build_table(
colors: &ColorConfig, colors: &ColorConfig,
fields: &[TorrentGetField], fields: &[TorrentGetField],
) -> Table<'static> { ) -> Table<'static> {
let row_style = row_style(colors); let select_style = select_style(colors);
let header_style = header_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 let rows = torrents
.iter() .iter()
.map(|t| make_row(t, fields, selected, row_style, colors)) .map(|t| make_row(t, fields, selected, select_style, colors))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let widths = fields let widths = fields
@ -28,12 +28,15 @@ pub fn build_table(
.map(|&f| Constraint::Length(f.width())) .map(|&f| Constraint::Length(f.width()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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) Table::new(rows, widths)
.block(default_block()) .block(default_block())
.header(header) .header(header)
.row_highlight_style(highlight_row_style) .row_highlight_style(highlight_row_style)
.highlight_symbol("")
.column_spacing(1) .column_spacing(1)
} }
@ -41,23 +44,26 @@ fn default_block() -> Block<'static> {
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .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 fg = to_color(&cfg.highlight_foreground);
let bg = to_color(&cfg.info_foreground); let bg = to_color(&cfg.highlight_background);
Style::default().bg(bg).fg(fg) Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
} }
fn header_style(cfg: &ColorConfig) -> Style { fn header_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.header_foreground); 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 { fn highlighted_row_style(cfg: &ColorConfig) -> Style {
let fg = to_color(&cfg.info_foreground); let fg = to_color(&cfg.highlight_foreground);
let bg = to_color(&cfg.highlight_foreground); let bg = to_color(&cfg.highlight_background);
Style::default().bg(bg).fg(fg) Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)
} }
fn make_row( fn make_row(