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:
Michel de Graaf
2026-07-24 21:18:20 +02:00
parent 2c29e1a39b
commit 105c798855
11 changed files with 362 additions and 365 deletions
+151 -63
View File
@@ -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::<Vec<_>>()
.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<String>, 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<Option<Color>> {
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);
}
}