mirror of
https://github.com/kristoferssolo/traxor.git
synced 2026-01-14 12:36:14 +00:00
refactor: update UI
This commit is contained in:
parent
1b145f9ace
commit
d0b8f8177b
@ -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"
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
133
src/ui/status.rs
133
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::<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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user