feat(ui): theme the TUI with the soulseek-rs design system
Replace the ANSI Light* palette with the design system's tokens (tokens/colors.css): oxide accent, phosphor focus, tape/signal/alarm semantics, paper text on dust chrome. Only foregrounds and the row highlight are painted so the user's terminal background shows through. Route every bordered pane through a single pane_block() helper carrying one column of horizontal padding, and give legends a consistent [n] Title form with the tag in signal. The command bar now derives its cursor position from the block's inner rect instead of a hardcoded border offset. Also collapses the download-status glyph match that was duplicated across three render sites into download_status_glyph().
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user