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]
# UI colors
highlight_background = "magenta"
highlight_foreground = "black"
highlight_background = "#3a3a5a"
highlight_foreground = "white"
header_foreground = "yellow"
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;
}
// 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);
let keybinds = &app.config.keybinds;

View File

@ -1,6 +1,7 @@
use crate::app::App;
use ratatui::{
prelude::*,
style::Modifier,
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 key_style = Style::default().fg(Color::Yellow).bold();
let select_key = display_key(&kb.select);
let filter_key = display_key(&kb.filter);
let rows = vec![
section_row("── Navigation ──"),
section_row("Navigation"),
key_row(&kb.prev_torrent, "Move up", key_style),
key_row(&kb.next_torrent, "Move down", key_style),
key_row(&kb.prev_tab, "Previous tab", key_style),
key_row(&kb.next_tab, "Next tab", key_style),
key_row(&kb.switch_tab_1, "All torrents", key_style),
key_row(&kb.switch_tab_2, "Active torrents", key_style),
key_row(&kb.switch_tab_3, "Downloading", key_style),
key_row("1-9, 0", "Switch to tab", key_style),
Row::default(),
section_row("── Actions ──"),
section_row("Actions"),
key_row(&kb.toggle_torrent, "Start/stop torrent", key_style),
key_row(&kb.toggle_all, "Start/stop all", 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_force, "Delete with data", key_style),
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.quit, "Quit", key_style),
];
#[allow(clippy::cast_possible_truncation)]
let height = rows.len() as u16 + 4;
let width = 40;
let width = 44;
let block = Block::default()
.title(" Keybindings ")
.title_style(Style::default().fg(Color::Cyan).bold())
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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 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> {
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 {

View File

@ -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;

View File

@ -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))
}

View File

@ -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::<i64>();
let total_up_speed = torrents.iter().filter_map(|t| t.rate_upload).sum::<i64>();
let total_downloaded = torrents
.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 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::<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()
.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);
}

View File

@ -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::<Vec<_>>();
let widths = fields
@ -28,12 +28,15 @@ pub fn build_table(
.map(|&f| Constraint::Length(f.width()))
.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)
.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(