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:
+151
-63
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user