Files
soulseek-rs-modded/soulseek-rs/src/ui/styles.rs
T
Michel de Graaf f1b667f7f9 refactor: cut what a whole-repo audit proved dead, padded, or hand-rolled
Seven region audits, every finding adversarially verified against the
whole workspace before being applied; 80 landed and the two that failed
in application were refuted honestly (ParseError has a from_str_radix
producer the audit missed; NatPmpError's Display is the only reader of
its diagnostic payloads).

The bigger cuts:

- The NewPeer/PierceFireWall cluster: an operation variant nothing ever
  sent, its 77-line arm, a protocol-wrong Client::pierce_firewall that
  wrote a peer-init frame down the server socket, and the struct only
  they used. The live paths in peer/listen.rs are untouched.
- The ops loop reads `for operation in reader` instead of hand-rolling
  recv/break, de-indenting the whole dispatch body.
- Dead lib surface: Message::print_hex and four unused accessors,
  UploadFailed, registry and actor getters, build_watch_user,
  strip_ansi_codes, delegating shims on ClientContext. Removing public
  items makes this a semver major, which the release pipeline enforces.
- Never-used flexibility: the state-migration framework that never held
  a migration, border-type helpers that all answered Rounded, the TUI's
  max_concurrent_downloads field nothing read.
- Convergence: six poll loops into poll_until, three wrap-around
  navigation blocks into cycle, hit tests into Rect::contains, char
  walkers into floor/ceil_char_boundary, five Download literals into
  one helper, the suites' shared scaffolding into tests/common.

Deliberately not taken: swapping the hand-rolled zlib inflate and MD5
for crates (they replace wire-facing machinery whose tolerance of
real-world peer quirks no local test exercises) and pulling chrono into
the lib for a log timestamp. Recorded for their own decision.

Net -1002 lines. Both e2e modes stay green: 94 CLI tests each way, 16
protocol, 33 lib, unit suites, clippy pedantic clean, aislop 100/100
with the four scanner false positives suppressed inline where they
fire.
2026-07-28 12:52:18 +02:00

262 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Reusable styles and colors for consistent UI appearance
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Padding};
use soulseek_rs::DownloadStatus;
/// 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);
pub const VINYL_2: Color = Color::Rgb(0x26, 0x21, 0x1D);
/// A warm oxide wash for the cursor row — dark enough that every column keeps
/// its own colour on top of it.
pub const SELECTION: Color = Color::Rgb(0x30, 0x1B, 0x14);
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);
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 = "→";
pub fn header_style() -> Style {
Style::default().fg(DUST).add_modifier(Modifier::BOLD)
}
pub fn highlight_style() -> Style {
Style::default()
.bg(SELECTION)
.fg(PAPER)
.add_modifier(Modifier::BOLD)
}
/// The cursor row in a table. No foreground: a table row already colours its
/// own cells, and setting one here would flatten them all to a single tone.
pub fn row_highlight_style() -> Style {
Style::default().bg(SELECTION).add_modifier(Modifier::BOLD)
}
pub fn success_style() -> Style {
Style::default().fg(COLOR_SUCCESS)
}
pub fn warning_style() -> Style {
Style::default().fg(COLOR_WARNING)
}
pub fn error_style() -> Style {
Style::default().fg(COLOR_ERROR)
}
pub fn inactive_style() -> Style {
Style::default().fg(COLOR_INACTIVE)
}
pub fn primary_style() -> Style {
Style::default().fg(COLOR_PRIMARY)
}
pub fn info_style() -> Style {
Style::default().fg(COLOR_INFO)
}
pub fn accent_style() -> Style {
Style::default().fg(COLOR_ACCENT)
}
pub fn dimmed_style() -> Style {
Style::default().fg(DUST)
}
pub fn body_style() -> Style {
Style::default().fg(PAPER_DIM)
}
pub fn focused_border_style() -> Style {
Style::default().fg(COLOR_FOCUSED_BORDER)
}
pub fn unfocused_border_style() -> Style {
Style::default().fg(DUST)
}
pub fn border_style(focused: bool) -> Style {
if focused {
focused_border_style()
} else {
unfocused_border_style()
}
}
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(BorderType::Rounded)
.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()),
}
}
pub fn format_shortcuts_styled(shortcuts: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (i, (key, action)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled("[", dimmed_style()));
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)
}
pub fn format_progress_bar(
progress: f64,
width: usize,
percentage: u8,
) -> Line<'static> {
let filled = (progress * width as f64) as usize;
let empty = width.saturating_sub(filled);
let spans = vec![
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),
),
];
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 table_cursor_row_tints_the_background_without_recolouring_cells() {
let cursor = row_highlight_style();
assert_eq!(cursor.fg, None);
assert_eq!(cursor.bg, Some(SELECTION));
}
#[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);
}
}