diff --git a/soulseek-rs/src/ui/download_selector.rs b/soulseek-rs/src/ui/download_selector.rs index 6d8a466..40e3170 100644 --- a/soulseek-rs/src/ui/download_selector.rs +++ b/soulseek-rs/src/ui/download_selector.rs @@ -1,8 +1,8 @@ use crate::models::FileDisplayData; use crate::ui::{ - BYTES_PER_MB, HIGHLIGHT_SYMBOL, border_style, border_type, format_bytes, - format_shortcuts_styled, get_bitrate, get_spinner_char, header_style, - highlight_style, primary_style, success_style, warning_style, + BYTES_PER_MB, HIGHLIGHT_SYMBOL, format_bytes, format_shortcuts_styled, + get_bitrate, get_spinner_char, header_style, highlight_style, pane_block, + plain_title, primary_style, success_style, warning_style, }; use color_eyre::Result; use ratatui::text::{Line, Span}; @@ -12,8 +12,8 @@ use ratatui::{ layout::{Alignment, Constraint, Layout}, style::Style, widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, - Table, TableState, Wrap, + Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table, + TableState, Wrap, }, }; use soulseek_rs::Client; @@ -466,13 +466,7 @@ impl FileSelector { let table = Table::new(rows, widths) .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title(title), - ) + .block(pane_block(true).title(title)) .column_spacing(1) .row_highlight_style(highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) @@ -538,12 +532,7 @@ impl FileSelector { let loading_widget = Paragraph::new(loading_message_line) .style(Style::default()) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(primary_style()) - .border_type(border_type(false)), - ) + .block(pane_block(false)) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); @@ -578,12 +567,7 @@ impl FileSelector { }; let info_widget = Paragraph::new(info_text) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title(title), - ) + .block(pane_block(false).title(plain_title(title, false))) .style(style); frame.render_widget(info_widget, info_area); @@ -617,12 +601,8 @@ impl FileSelector { ]) }; - let controls_widget = Paragraph::new(controls_line).block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title("Controls"), - ); + let controls_widget = Paragraph::new(controls_line) + .block(pane_block(false).title(plain_title("Controls", false))); frame.render_widget(controls_widget, area); } diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 9b0544b..ac8cf18 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -1,9 +1,10 @@ use crate::models::DownloadEntry; use crate::ui::{ - BYTES_PER_MB, COLOR_PRIMARY, HIGHLIGHT_SYMBOL, border_style, border_type, - error_style, format_bytes_progress, format_progress_bar, - format_shortcuts_styled, format_speed, header_style, highlight_style, - inactive_style, info_style, primary_style, warning_style, + BYTES_PER_MB, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style, + download_status_glyph, error_style, format_bytes_progress, + format_progress_bar, format_shortcuts_styled, format_speed, header_style, + highlight_style, info_style, pane_block, plain_title, primary_style, + success_style, warning_style, }; use color_eyre::Result; use ratatui::{ @@ -13,8 +14,8 @@ use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, - Table, TableState, + Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table, + TableState, }, }; use soulseek_rs::{Client, DownloadStatus}; @@ -221,14 +222,8 @@ impl MultiDownloadProgress { .iter() .map(|download_entry| { let download = &download_entry.download; - let status_icon = match download.status { - DownloadStatus::Queued => "⋯", - DownloadStatus::InProgress { .. } => "⧗", - DownloadStatus::Paused { .. } => "⏸", - DownloadStatus::Completed => "✓", - DownloadStatus::Failed(_) => "✗", - DownloadStatus::TimedOut => "⏱", - }; + let (status_icon, status_style) = + download_status_glyph(&download.status); let progress = if download.size > 0 { download.bytes_downloaded() as f64 / download.size as f64 @@ -254,25 +249,15 @@ impl MultiDownloadProgress { }; let cells = vec![ - Cell::from(status_icon), - Cell::from(download.filename.clone()), - Cell::from(download.username.clone()), - Cell::from(size_str), + Cell::from(status_icon).style(status_style), + Cell::from(download.filename.clone()).style(body_style()), + Cell::from(download.username.clone()).style(info_style()), + Cell::from(size_str).style(warning_style()), Cell::from(progress_bar), - Cell::from(speed_str), + Cell::from(speed_str).style(dimmed_style()), ]; - let style = match download.status { - DownloadStatus::Queued => inactive_style(), - DownloadStatus::InProgress { .. } => warning_style(), - DownloadStatus::Paused { .. } => info_style(), - DownloadStatus::Completed => primary_style(), - DownloadStatus::Failed(_) | DownloadStatus::TimedOut => { - error_style() - } - }; - - Row::new(cells).style(style).height(1) + Row::new(cells).height(1) }) .collect(); @@ -287,13 +272,7 @@ impl MultiDownloadProgress { let table = Table::new(rows, widths) .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title("Downloads"), - ) + .block(pane_block(true).title(plain_title("Downloads", true))) .column_spacing(1) .row_highlight_style(highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) @@ -314,12 +293,8 @@ impl MultiDownloadProgress { ("Esc/q", "cancel all"), ]); - let controls_widget = Paragraph::new(controls_line).block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title("Controls"), - ); + let controls_widget = Paragraph::new(controls_line) + .block(pane_block(false).title(plain_title("Controls", false))); frame.render_widget(controls_widget, area); } @@ -378,10 +353,7 @@ pub fn render_download_stats( .sum(); let speed_mb = (total_speed / BYTES_PER_MB * 100.0).round() / 100.0; - let block = Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title("Status"); + let block = pane_block(false).title(plain_title("Status", false)); let inner_area = block.inner(area); frame.render_widget(block, area); @@ -393,52 +365,23 @@ pub fn render_download_stats( ]) .split(inner_area); - // Left container: Statistics with styled values + let count = |value: usize, style: Style| { + Span::styled(value.to_string(), style.add_modifier(Modifier::BOLD)) + }; let stats_line = Line::from(vec![ - Span::raw("soulseek-rs 🦀 "), - Span::styled( - format!("v{VERSION} "), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" Downloads: "), - Span::styled( - active_count.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" active, "), - Span::styled( - completed.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" completed, "), - Span::styled( - failed.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" failed, "), - Span::styled( - queued.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" queued"), - Span::raw(", "), - Span::styled( - paused.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" paused"), + Span::styled("soulseek-rs", primary_style()), + Span::styled(format!(" 🦀 v{VERSION}"), dimmed_style()), + Span::styled(" Downloads: ", dimmed_style()), + count(active_count, accent_style()), + Span::styled(" active, ", dimmed_style()), + count(completed, success_style()), + Span::styled(" completed, ", dimmed_style()), + count(failed, error_style()), + Span::styled(" failed, ", dimmed_style()), + count(queued, warning_style()), + Span::styled(" queued, ", dimmed_style()), + count(paused, info_style()), + Span::styled(" paused", dimmed_style()), ]); let stats_paragraph = Paragraph::new(stats_line); @@ -453,15 +396,13 @@ pub fn render_download_stats( let mut spans: Vec = Vec::new(); spans.extend(data_str.spans); - spans.push(Span::raw(" • ")); + spans.push(Span::styled(" · ", dimmed_style())); spans.push(Span::styled( format!("{speed_mb}"), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), + warning_style().add_modifier(Modifier::BOLD), )); - spans.push(Span::raw(" MB/s")); - spans.push(Span::raw(" • ")); + spans.push(Span::styled(" MB/s", dimmed_style())); + spans.push(Span::styled(" · ", dimmed_style())); spans.extend(progress_bar.spans); let progress_line = Line::from(spans); diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index 2d9b7c7..f126c79 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -5,14 +5,18 @@ //! machine so it can be tested without a terminal; the IO loop //! ([`run_login_flow`]) drives it against a real terminal and client. +use crate::ui::{ + GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, + pane_block, primary_style, title_style, warning_style, +}; use color_eyre::Result; use ratatui::{ DefaultTerminal, Frame, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll}, layout::{Constraint, Flex, Layout, Rect}, - style::{Color, Modifier, Style}, + style::Modifier, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Clear, Paragraph}, }; use soulseek_rs::{Client, ClientSettings}; use std::sync::mpsc::{Receiver, channel}; @@ -231,10 +235,10 @@ fn render(frame: &mut Frame, form: &LoginForm) { let area = centered(frame.area(), 52, 12); frame.render_widget(Clear, area); - let block = Block::default() - .borders(Borders::ALL) - .title(" Soulseek Login ") - .title_style(Style::default().add_modifier(Modifier::BOLD)); + let block = pane_block(true).title(Line::from(vec![ + Span::styled(" soulseek-rs", primary_style()), + Span::styled(" login ", title_style(true)), + ])); let inner = block.inner(area); frame.render_widget(block, area); @@ -249,16 +253,19 @@ fn render(frame: &mut Frame, form: &LoginForm) { .split(inner); let field = |label: &str, value: String, focused: bool| { - let marker = if focused { "> " } else { " " }; + let marker = if focused { "› " } else { " " }; let style = if focused { - Style::default().add_modifier(Modifier::BOLD) + info_style().add_modifier(Modifier::BOLD) } else { - Style::default() + dimmed_style() }; Paragraph::new(Line::from(vec![ Span::styled(format!("{marker}{label:<10}"), style), - Span::raw(value), - Span::raw(if focused { "▏" } else { "" }), + Span::styled(value, primary_style()), + Span::styled( + if focused { GLYPH_CURSOR } else { "" }, + accent_style(), + ), ])) }; @@ -284,10 +291,10 @@ fn render(frame: &mut Frame, form: &LoginForm) { LoginPhase::Editing => Paragraph::new(""), LoginPhase::Connecting => Paragraph::new(Line::from(Span::styled( "Connecting…", - Style::default().fg(Color::Yellow), + warning_style(), ))), LoginPhase::Failed(message) => Paragraph::new(Line::from( - Span::styled(message.clone(), Style::default().fg(Color::Red)), + Span::styled(message.clone(), error_style()), )) .wrap(ratatui::widgets::Wrap { trim: true }), }; @@ -298,7 +305,7 @@ fn render(frame: &mut Frame, form: &LoginForm) { "New usernames are registered automatically.\n\ Tab: switch · Enter: log in · Esc: quit", ) - .style(Style::default().fg(Color::DarkGray)), + .style(dimmed_style()), rows[5], ); } diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index df248f8..38fef37 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -6,12 +6,14 @@ use crate::ui::panes::{ render_searches_pane, }; use crate::ui::{ - border_style, border_type, format_shortcuts_styled, render_download_stats, + accent_style, format_shortcuts_styled, pane_block, plain_title, + primary_style, render_download_stats, }; use ratatui::{ Frame, layout::{Constraint, Layout, Position, Rect}, - widgets::{Block, Borders, Paragraph}, + text::{Line, Span}, + widgets::Paragraph, }; const COMMAND_BAR_PREFIX: &str = "search: "; @@ -239,9 +241,7 @@ impl MainTui { lines.push(ratatui::text::Line::from(status.clone())); } - let block = ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::ALL) - .title(" Settings "); + let block = pane_block(true).title(plain_title("Settings", true)); frame.render_widget( ratatui::widgets::Paragraph::new(lines) .block(block) @@ -275,13 +275,11 @@ impl MainTui { .collect() }; - let popup = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title(" Messages (m: compose, i/Esc: close) "), - ); + let popup = + Paragraph::new(lines).block(pane_block(true).title(plain_title( + "Messages · m: compose · i/Esc: close", + true, + ))); frame.render_widget(ratatui::widgets::Clear, area); frame.render_widget(popup, area); @@ -436,45 +434,39 @@ impl MainTui { more => format!("{} folders", more.len()), }; let title = format!("Shortcuts · Sharing: {sharing}"); - let shortcuts_widget = Paragraph::new(shortcuts_line).block( - Block::default() - .borders(Borders::ALL) - .border_type(border_type(false)) - .title(title), - ); + let shortcuts_widget = Paragraph::new(shortcuts_line) + .block(pane_block(false).title(plain_title(title, false))); frame.render_widget(shortcuts_widget, area); } fn render_command_bar(&self, frame: &mut Frame, area: Rect) { let prefix = command_bar_prefix(self.state.command_bar_mode); - let content_width = area.width.saturating_sub(2); + let block = pane_block(true); + // Derive from the block's inner rect so borders and padding stay in + // one place; the cursor follows whatever the pane reserves. + let inner = block.inner(area); let prefix_width = prefix.chars().count() as u16; - let input_width = content_width.saturating_sub(prefix_width); + let input_width = inner.width.saturating_sub(prefix_width); let (visible_input, cursor_column) = visible_input_at_cursor( &self.state.command_bar_input, self.state.command_bar_cursor_position, input_width, ); - let command_text = format!("{prefix}{visible_input}"); + let command_line = Line::from(vec![ + Span::styled(prefix, accent_style()), + Span::styled(visible_input, primary_style()), + ]); - let paragraph = Paragraph::new(command_text).block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)), - ); + frame.render_widget(Paragraph::new(command_line).block(block), area); - frame.render_widget(paragraph, area); - - if area.width > 2 && area.height > 2 { - let cursor_x = area + if inner.width > 0 && inner.height > 0 { + let cursor_x = inner .x - .saturating_add(1) .saturating_add(prefix_width) .saturating_add(cursor_column) - .min(area.x.saturating_add(area.width.saturating_sub(2))); - frame.set_cursor_position(Position::new(cursor_x, area.y + 1)); + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position(Position::new(cursor_x, inner.y)); } } } diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index e963361..9ea104c 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,17 +1,14 @@ use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; use crate::ui::{ - HIGHLIGHT_SYMBOL, border_style, border_type, dimmed_style, error_style, - format_bytes, get_spinner_char, highlight_style, primary_style, + HIGHLIGHT_SYMBOL, dimmed_style, error_style, format_bytes, + get_spinner_char, highlight_style, pane_block, primary_style, }; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::Modifier, text::{Line, Span}, - widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, - TableState, - }, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; /// Render the browse popup: a tab bar of browsed users (when more than one is @@ -70,11 +67,7 @@ fn render_browse_one( ), _ => format!(" Browse {} ", browse.username), }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title(title); + let block = pane_block(true).title(title); match browse.status { BrowseStatus::Loading => { diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs index 1f55cad..9968190 100644 --- a/soulseek-rs/src/ui/panes/download_info_pane.rs +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -1,7 +1,7 @@ use crate::models::DownloadEntry; use crate::ui::{ - border_style, border_type, dimmed_style, error_style, format_bytes, - format_progress_bar, format_speed, inactive_style, info_style, + accent_style, dimmed_style, error_style, format_bytes, format_progress_bar, + format_speed, inactive_style, info_style, pane_block, pane_title, primary_style, success_style, warning_style, }; use ratatui::{ @@ -9,7 +9,7 @@ use ratatui::{ layout::Rect, style::Style, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Paragraph, Wrap}, }; use soulseek_rs::DownloadStatus; use soulseek_rs::utils::path::expand_tilde; @@ -22,11 +22,7 @@ pub fn render_download_info_pane( selected: Option<&DownloadEntry>, focused: bool, ) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title("[Info]"); + let block = pane_block(focused).title(pane_title("Info", "", focused)); let Some(entry) = selected else { let paragraph = Paragraph::new(Line::from(Span::styled( @@ -55,15 +51,23 @@ fn build_info_lines( lines.push(Line::from(Span::styled( basename, - primary_style().add_modifier(ratatui::style::Modifier::BOLD), + accent_style().add_modifier(ratatui::style::Modifier::BOLD), ))); if !parent_dir.is_empty() { lines.push(Line::from(Span::styled(parent_dir, dimmed_style()))); } lines.push(Line::from("")); - lines.push(label_value("User", &download.username)); - lines.push(label_value("Size", &format_bytes(download.size))); + lines.push(label_value_styled( + "User", + download.username.clone(), + info_style(), + )); + lines.push(label_value_styled( + "Size", + format_bytes(download.size), + warning_style(), + )); let (status_text, status_style) = match &download.status { DownloadStatus::Queued => ("Queued".to_string(), inactive_style()), @@ -80,7 +84,7 @@ fn build_info_lines( let save_path = expand_tilde(&download.download_directory) .to_string_lossy() .to_string(); - lines.push(label_value("Save to", &save_path)); + lines.push(label_value_styled("Save to", save_path, info_style())); if let Some(bitrate) = download.metadata.bitrate { lines.push(label_value("Bitrate", &format!("{bitrate} kbps"))); diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index 2605f14..ff77836 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -1,16 +1,14 @@ use crate::models::DownloadEntry; use crate::ui::{ - HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes, + GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, HIGHLIGHT_SYMBOL, accent_style, + body_style, dimmed_style, download_status_glyph, error_style, format_bytes, format_speed, header_style, highlight_style, inactive_style, info_style, - success_style, warning_style, + pane_block, pane_title, primary_style, success_style, warning_style, }; use ratatui::{ Frame, layout::Rect, - widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, - TableState, - }, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use soulseek_rs::DownloadStatus; use soulseek_rs::types::{UploadInfo, UploadStatus}; @@ -24,15 +22,16 @@ pub fn render_downloads_pane( focused: bool, ) { if downloads.is_empty() && uploads.is_empty() { - let empty_block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title("[3] Downloads/Uploads"); + let empty_block = pane_block(focused).title(pane_title( + "3", + "Downloads/Uploads", + focused, + )); let paragraph = Paragraph::new( - "No transfers. Select files from Results and press Enter.", + "No transfers. Select files in [2] Results and press Enter.", ) + .style(dimmed_style()) .block(empty_block); frame.render_widget(paragraph, area); return; @@ -51,14 +50,8 @@ pub fn render_downloads_pane( .iter() .map(|download_entry| { let download = &download_entry.download; - let (status_icon, status_style) = match &download.status { - DownloadStatus::Queued => ("⋯", inactive_style()), - DownloadStatus::InProgress { .. } => ("⧗", warning_style()), - DownloadStatus::Paused { .. } => ("⏸", info_style()), - DownloadStatus::Completed => ("✓", success_style()), - DownloadStatus::Failed(_) => ("✗", error_style()), - DownloadStatus::TimedOut => ("⏱", error_style()), - }; + let (status_icon, status_style) = + download_status_glyph(&download.status); let progress_text = match &download.status { DownloadStatus::Queued => "Queued".to_string(), @@ -104,22 +97,31 @@ pub fn render_downloads_pane( _ => "-".to_string(), }; + let progress_style = match &download.status { + DownloadStatus::Completed => success_style(), + DownloadStatus::Failed(_) | DownloadStatus::TimedOut => { + error_style() + } + DownloadStatus::Queued => inactive_style(), + _ => primary_style(), + }; + Row::new(vec![ Cell::from(status_icon).style(status_style), - Cell::from(download.filename.clone()), - Cell::from(download.username.clone()), - Cell::from(progress_text), - Cell::from(speed_text), + Cell::from(download.filename.clone()).style(body_style()), + Cell::from(download.username.clone()).style(info_style()), + Cell::from(progress_text).style(progress_style), + Cell::from(speed_text).style(warning_style()), ]) }) .collect(); rows.extend(uploads.iter().map(|upload| { let (status_icon, status_style) = match &upload.status { - UploadStatus::InProgress => ("⧗", warning_style()), - UploadStatus::Completed => ("✓", success_style()), - UploadStatus::Cancelled => ("✗", inactive_style()), - UploadStatus::Failed(_) => ("✗", error_style()), + UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), + UploadStatus::Completed => (GLYPH_DONE, success_style()), + UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()), + UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()), }; let progress_text = match &upload.status { UploadStatus::InProgress => { @@ -147,9 +149,9 @@ pub fn render_downloads_pane( .unwrap_or(&upload.filename); Row::new(vec![ Cell::from(format!("↑ {status_icon}")).style(status_style), - Cell::from(basename.to_string()), - Cell::from(upload.username.clone()), - Cell::from(progress_text), + Cell::from(basename.to_string()).style(body_style()), + Cell::from(upload.username.clone()).style(info_style()), + Cell::from(progress_text).style(primary_style()), Cell::from(String::new()), ]) })); @@ -167,13 +169,11 @@ pub fn render_downloads_pane( .row_highlight_style(highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_spacing(HighlightSpacing::Always) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title("[3] Downloads/Uploads"), - ); + .block(pane_block(focused).title(pane_title( + "3", + "Downloads/Uploads", + focused, + ))); frame.render_stateful_widget(table, area, table_state); } diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 757b16c..f29af08 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -1,15 +1,14 @@ use crate::models::FileDisplayData; use crate::ui::{ - BYTES_PER_MB, HIGHLIGHT_SYMBOL, border_style, border_type, format_bytes, - header_style, highlight_style, + BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, + header_style, highlight_style, info_style, pane_block, pane_title, + success_style, warning_style, }; use ratatui::{ Frame, layout::Rect, - widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, - TableState, - }, + text::{Line, Span}, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; use std::collections::HashSet; @@ -62,26 +61,32 @@ pub fn render_results_pane( active_search_query, } = params; if items.is_empty() { - let title = if let Some(query) = active_search_query { - format!("[2] Results: {query}") - } else { - "[2] Results".to_string() + let title = match active_search_query { + Some(query) => format!("Results: {query}"), + None => "Results".to_string(), }; - let empty_block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title(title); + let empty_block = + pane_block(focused).title(pane_title("2", &title, focused)); let message = if is_filtering { - format!("No results match filter: '{filter_query}'") + vec![Line::from(Span::styled( + format!("No results match filter: '{filter_query}'"), + dimmed_style(), + ))] } else { - format!( - "soulseek-rs 🦀 v{VERSION} -Michel de Graaf 2026\n -No results. Select a search from the Searches pane [1]. Or start new search [s → search] \n" - ) + vec![ + Line::from(vec![ + Span::styled("soulseek-rs", body_style()), + Span::styled(format!(" v{VERSION}"), dimmed_style()), + ]), + Line::from(""), + Line::from(Span::styled("No results yet.", body_style())), + Line::from(Span::styled( + "Pick a search in [1] Searches, or start a new one.", + dimmed_style(), + )), + ] }; let paragraph = Paragraph::new(message).block(empty_block); @@ -124,14 +129,20 @@ No results. Select a search from the Searches pane [1]. Or start new search [s "-".to_string() }; + let checkbox_style = if checkbox == "[✓]" { + success_style() + } else { + dimmed_style() + }; + Row::new(vec![ - Cell::from(checkbox), - Cell::from(file.filename.clone()), - Cell::from(format_bytes(file.size)), - Cell::from(file.username.clone()), - Cell::from(bitrate_str), - Cell::from(speed_str), - Cell::from(file.slots.to_string()), + Cell::from(checkbox).style(checkbox_style), + Cell::from(file.filename.clone()).style(body_style()), + Cell::from(format_bytes(file.size)).style(warning_style()), + Cell::from(file.username.clone()).style(info_style()), + Cell::from(bitrate_str).style(dimmed_style()), + Cell::from(speed_str).style(dimmed_style()), + Cell::from(file.slots.to_string()).style(dimmed_style()), ]) }) .collect(); @@ -147,11 +158,11 @@ No results. Select a search from the Searches pane [1]. Or start new search [s ]; let title = if is_filtering { - format!("[2] Results - Filter: '{filter_query}'") + format!("Results · filter: '{filter_query}'") } else if let Some(query) = active_search_query { - format!("[2] Results: {query}") + format!("Results: {query}") } else { - "[2] Results".to_string() + "Results".to_string() }; let table = Table::new(rows, widths) @@ -159,13 +170,7 @@ No results. Select a search from the Searches pane [1]. Or start new search [s .row_highlight_style(highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_spacing(HighlightSpacing::Always) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title(title), - ); + .block(pane_block(focused).title(pane_title("2", &title, focused))); frame.render_stateful_widget(table, area, table_state); } diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index d7775e3..da714fb 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,7 +1,7 @@ use crate::models::{RoomsState, RoomsView}; use crate::ui::{ - HIGHLIGHT_SYMBOL, accent_style, border_style, border_type, dimmed_style, - highlight_style, info_style, primary_style, + HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, + highlight_style, info_style, pane_block, plain_title, primary_style, }; use ratatui::{ Frame, @@ -43,11 +43,7 @@ fn render_list( " Rooms (Enter: join, /: filter, Tab: open rooms, Esc: close) " .to_string() }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title(title); + let block = pane_block(true).title(title); let filtered = rooms.filtered_rooms(); if filtered.is_empty() { @@ -98,13 +94,9 @@ fn render_list( } fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style(true)) - .border_type(border_type(true)) - .title( - " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", - ); + let block = pane_block(true).title( + " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", + ); let inner = block.inner(area); frame.render_widget(block, area); @@ -209,7 +201,8 @@ fn render_users( let block = Block::default() .borders(Borders::LEFT) .border_style(dimmed_style()) - .title(format!(" Users ({}) ", users.len())); + .padding(PANE_PADDING) + .title(plain_title(format!("Users ({})", users.len()), false)); let inner = block.inner(area); frame.render_widget(block, area); diff --git a/soulseek-rs/src/ui/panes/searches_pane.rs b/soulseek-rs/src/ui/panes/searches_pane.rs index 3fbddc1..835dc85 100644 --- a/soulseek-rs/src/ui/panes/searches_pane.rs +++ b/soulseek-rs/src/ui/panes/searches_pane.rs @@ -1,12 +1,12 @@ use crate::models::{SearchEntry, SearchStatus}; use crate::ui::{ - HIGHLIGHT_SYMBOL, border_style, border_type, header_style, highlight_style, - success_style, warning_style, + HIGHLIGHT_SYMBOL, body_style, dimmed_style, header_style, highlight_style, + pane_block, pane_title, success_style, warning_style, }; use ratatui::{ Frame, layout::Rect, - widgets::{Block, Borders, Cell, HighlightSpacing, Row, Table, TableState}, + widgets::{Cell, HighlightSpacing, Row, Table, TableState}, }; pub fn render_searches_pane( @@ -46,8 +46,8 @@ pub fn render_searches_pane( Row::new(vec![ status_cell, - Cell::from(search.query.clone()), - Cell::from(results_text), + Cell::from(search.query.clone()).style(body_style()), + Cell::from(results_text).style(dimmed_style()), ]) }) .collect(); @@ -63,13 +63,7 @@ pub fn render_searches_pane( .row_highlight_style(highlight_style()) .highlight_symbol(HIGHLIGHT_SYMBOL) .highlight_spacing(HighlightSpacing::Always) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style(focused)) - .border_type(border_type(focused)) - .title("[1] Searches"), - ); + .block(pane_block(focused).title(pane_title("1", "Searches", focused))); frame.render_stateful_widget(table, area, table_state); } diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 505c2f7..9d24559 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -2,32 +2,52 @@ #![allow(dead_code)] use ratatui::style::{Color, Modifier, Style}; -use ratatui::symbols; use ratatui::text::{Line, Span}; -use ratatui::widgets::BorderType; +use ratatui::widgets::{Block, BorderType, Borders, Padding}; +use soulseek_rs::DownloadStatus; -// Color scheme - using Light variants to match openapi-tui -pub const COLOR_PRIMARY: Color = Color::LightCyan; -pub const COLOR_SUCCESS: Color = Color::LightGreen; -pub const COLOR_WARNING: Color = Color::LightYellow; -pub const COLOR_ERROR: Color = Color::LightRed; -pub const COLOR_INACTIVE: Color = Color::Gray; -pub const COLOR_INFO: Color = Color::LightBlue; -pub const COLOR_ACCENT: Color = Color::LightMagenta; +/// One column of breathing room on each side, so content never touches the +/// border. Vertical rows stay unpadded — they are too scarce in a terminal. +pub const PANE_PADDING: Padding = Padding::horizontal(1); -// Border colors -pub const COLOR_FOCUSED_BORDER: Color = Color::LightGreen; +pub const VINYL_2: Color = Color::Rgb(0x26, 0x21, 0x1D); +pub const DUST: Color = Color::Rgb(0x8A, 0x81, 0x78); +pub const PAPER: Color = Color::Rgb(0xE8, 0xE1, 0xD6); +pub const PAPER_DIM: Color = Color::Rgb(0xC9, 0xC1, 0xB4); +pub const OXIDE: Color = Color::Rgb(0xC1, 0x50, 0x2E); +pub const TAPE: Color = Color::Rgb(0xD8, 0xA6, 0x57); +pub const PHOSPHOR: Color = Color::Rgb(0x7F, 0xB6, 0x85); +pub const SIGNAL: Color = Color::Rgb(0x6E, 0x9D, 0xC9); +pub const ALARM: Color = Color::Rgb(0xB3, 0x3A, 0x3A); -// Highlight symbol - using arrow like openapi-tui -pub const HIGHLIGHT_SYMBOL: &str = symbols::scrollbar::HORIZONTAL.end; +pub const COLOR_PRIMARY: Color = PAPER; +pub const COLOR_ACCENT: Color = OXIDE; +pub const COLOR_SUCCESS: Color = PHOSPHOR; +pub const COLOR_WARNING: Color = TAPE; +pub const COLOR_ERROR: Color = ALARM; +pub const COLOR_INFO: Color = SIGNAL; +pub const COLOR_INACTIVE: Color = DUST; +pub const COLOR_FOCUSED_BORDER: Color = PHOSPHOR; + +pub const GLYPH_QUEUED: &str = "⋯"; +pub const GLYPH_ACTIVE: &str = "↯"; +pub const GLYPH_PAUSED: &str = "⏸"; +pub const GLYPH_DONE: &str = "✓"; +pub const GLYPH_FAILED: &str = "✗"; +pub const GLYPH_TIMED_OUT: &str = "⧗"; +pub const GLYPH_CURSOR: &str = "▮"; +pub const HIGHLIGHT_SYMBOL: &str = "›"; +pub const SHORTCUT_ARROW: &str = "→"; -// Reusable styles pub fn header_style() -> Style { - Style::default().add_modifier(Modifier::BOLD) + Style::default().fg(DUST).add_modifier(Modifier::BOLD) } pub fn highlight_style() -> Style { - Style::default().add_modifier(Modifier::BOLD) + Style::default() + .bg(VINYL_2) + .fg(PAPER) + .add_modifier(Modifier::BOLD) } pub fn success_style() -> Style { @@ -59,16 +79,19 @@ pub fn accent_style() -> Style { } pub fn dimmed_style() -> Style { - Style::default().add_modifier(Modifier::DIM) + Style::default().fg(DUST) +} + +pub fn body_style() -> Style { + Style::default().fg(PAPER_DIM) } -// Focus-based border styling (matching openapi-tui) pub fn focused_border_style() -> Style { Style::default().fg(COLOR_FOCUSED_BORDER) } pub fn unfocused_border_style() -> Style { - Style::default() + Style::default().fg(DUST) } pub const fn focused_border_type() -> BorderType { @@ -79,7 +102,6 @@ pub const fn unfocused_border_type() -> BorderType { BorderType::Rounded } -// Helper function to get border style based on focus state pub fn border_style(focused: bool) -> Style { if focused { focused_border_style() @@ -96,16 +118,53 @@ pub const fn border_type(focused: bool) -> BorderType { } } -// Shortcut formatting helper (matching openapi-tui format) -pub fn format_shortcuts(shortcuts: &[(&str, &str)]) -> String { - shortcuts - .iter() - .map(|(key, action)| format!("[{key} {HIGHLIGHT_SYMBOL} {action}]")) - .collect::>() - .join(" ") +pub fn title_style(focused: bool) -> Style { + if focused { + Style::default().fg(PHOSPHOR).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(DUST) + } +} + +pub fn pane_title(tag: &str, title: &str, focused: bool) -> Line<'static> { + let label = if title.is_empty() { + " ".to_string() + } else { + format!(" {title} ") + }; + Line::from(vec![ + Span::styled(format!(" [{tag}]"), Style::default().fg(SIGNAL)), + Span::styled(label, title_style(focused)), + ]) +} + +pub fn plain_title(title: impl Into, focused: bool) -> Line<'static> { + Line::from(Span::styled( + format!(" {} ", title.into()), + title_style(focused), + )) +} + +/// The standard bordered pane. Chain `.title(..)` for a legend. +pub fn pane_block(focused: bool) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_style(border_style(focused)) + .border_type(border_type(focused)) + .padding(PANE_PADDING) +} + +pub fn download_status_glyph(status: &DownloadStatus) -> (&'static str, Style) { + match status { + DownloadStatus::Queued => (GLYPH_QUEUED, warning_style()), + DownloadStatus::InProgress { .. } => (GLYPH_ACTIVE, accent_style()), + DownloadStatus::Paused { .. } => (GLYPH_PAUSED, info_style()), + DownloadStatus::Completed => (GLYPH_DONE, success_style()), + DownloadStatus::Failed(_) => (GLYPH_FAILED, error_style()), + DownloadStatus::TimedOut => (GLYPH_TIMED_OUT, error_style()), + } } -// Styled shortcut formatting helper (returns Line with colored Spans) pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> { let mut spans = Vec::new(); @@ -115,36 +174,15 @@ pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> { } spans.push(Span::styled("[", dimmed_style())); - spans.push(Span::styled( - key.to_string(), - Style::default() - .fg(COLOR_PRIMARY) - .add_modifier(Modifier::BOLD), - )); - spans.push(Span::styled( - format!(" {HIGHLIGHT_SYMBOL} "), - dimmed_style(), - )); - spans.push(Span::raw(action.to_string())); + spans.push(Span::styled(key.to_string(), Style::default().fg(SIGNAL))); + spans.push(Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style())); + spans.push(Span::styled(action.to_string(), body_style())); spans.push(Span::styled("]", dimmed_style())); } Line::from(spans) } -// Status colors for downloads -pub fn status_color(status: &str) -> Color { - match status { - "queued" => COLOR_INACTIVE, - "in_progress" => COLOR_WARNING, - "paused" => COLOR_INFO, - "completed" => COLOR_PRIMARY, - "failed" | "timed_out" => COLOR_ERROR, - _ => Color::default(), - } -} - -// Format progress bar with styled filled/unfilled portions pub fn format_progress_bar( progress: f64, width: usize, @@ -153,22 +191,72 @@ pub fn format_progress_bar( let filled = (progress * width as f64) as usize; let empty = width.saturating_sub(filled); - let filled_part = "█".repeat(filled); - let empty_part = "░".repeat(empty); - let spans = vec![ - Span::raw("["), - Span::styled(filled_part, primary_style()), - Span::styled(empty_part, dimmed_style()), - Span::raw("]"), + Span::styled("[", dimmed_style()), + Span::styled("█".repeat(filled), Style::default().fg(SIGNAL)), + Span::styled("░".repeat(empty), Style::default().fg(VINYL_2)), + Span::styled("]", dimmed_style()), Span::raw(" "), Span::styled( format!("{percentage}%"), - primary_style() - .add_modifier(Modifier::BOLD) - .fg(Color::default()), + primary_style().add_modifier(Modifier::BOLD), ), ]; Line::from(spans) } + +#[cfg(test)] +mod tests { + use super::*; + + fn colors(line: &Line<'static>) -> Vec> { + line.spans.iter().map(|s| s.style.fg).collect() + } + + #[test] + fn pane_title_tags_in_signal_and_tracks_focus() { + assert_eq!( + colors(&pane_title("2", "Results", true)), + vec![Some(SIGNAL), Some(PHOSPHOR)] + ); + assert_eq!( + colors(&pane_title("2", "Results", false)), + vec![Some(SIGNAL), Some(DUST)] + ); + } + + #[test] + fn titles_are_inset_from_the_border() { + let inset = |line: &Line<'static>| { + let text: String = + line.spans.iter().map(|s| s.content.as_ref()).collect(); + text.starts_with(' ') && text.ends_with(' ') + }; + assert!(inset(&pane_title("2", "Results", true))); + assert!(inset(&pane_title("Info", "", true))); + assert!(inset(&plain_title("Status", false))); + } + + #[test] + fn download_status_glyphs_carry_their_semantic_color() { + for (status, glyph, color) in [ + (DownloadStatus::Queued, GLYPH_QUEUED, TAPE), + (DownloadStatus::Completed, GLYPH_DONE, PHOSPHOR), + (DownloadStatus::Failed(None), GLYPH_FAILED, ALARM), + (DownloadStatus::TimedOut, GLYPH_TIMED_OUT, ALARM), + ] { + let (got_glyph, style) = download_status_glyph(&status); + assert_eq!(got_glyph, glyph); + assert_eq!(style.fg, Some(color)); + } + } + + #[test] + fn progress_bar_splits_fill_and_track() { + let bar = format_progress_bar(0.25, 20, 25); + assert_eq!(bar.spans[1].content.chars().count(), 5); + assert_eq!(bar.spans[1].style.fg, Some(SIGNAL)); + assert_eq!(bar.spans[2].content.chars().count(), 15); + } +}