The results pane now has the whole width, where a long file name needs it, and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab walk the panes, z zooms the focused one to the window, w hides it so the others widen, and its number brings it back. Every list takes the same navigation keys, the shortcuts bar wraps instead of cutting keys off, and ? lists every key by pane, scrolling on a short terminal. Follows up on #55.
341 lines
11 KiB
Rust
341 lines
11 KiB
Rust
// 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::Cancelled => (GLYPH_FAILED, inactive_style()),
|
||
DownloadStatus::Failed(_) => (GLYPH_FAILED, error_style()),
|
||
DownloadStatus::TimedOut => (GLYPH_TIMED_OUT, error_style()),
|
||
}
|
||
}
|
||
|
||
/// One `[key → action]` legend entry.
|
||
fn shortcut_spans(key: &str, action: &str) -> Vec<Span<'static>> {
|
||
vec![
|
||
Span::styled("[", dimmed_style()),
|
||
Span::styled(key.to_string(), Style::default().fg(SIGNAL)),
|
||
Span::styled(format!(" {SHORTCUT_ARROW} "), dimmed_style()),
|
||
Span::styled(action.to_string(), body_style()),
|
||
Span::styled("]", dimmed_style()),
|
||
]
|
||
}
|
||
|
||
/// The most rows the shortcuts bar grows to. Past that a terminal is too
|
||
/// narrow for the legend to be worth the content rows it would take.
|
||
pub const SHORTCUT_ROWS_MAX: usize = 3;
|
||
|
||
/// Lay the legend out in rows no wider than `width`, never breaking inside an
|
||
/// entry: a `[key → action]` split over two rows reads as two keys. Entries
|
||
/// that do not fit in [`SHORTCUT_ROWS_MAX`] rows are dropped, so a bar always
|
||
/// has at least one row and never more than that.
|
||
pub fn pack_shortcuts(
|
||
shortcuts: &[(&str, &str)],
|
||
width: u16,
|
||
) -> Vec<Line<'static>> {
|
||
let width = usize::from(width);
|
||
let mut rows: Vec<Line<'static>> = Vec::new();
|
||
let mut row: Vec<Span<'static>> = Vec::new();
|
||
let mut row_width = 0;
|
||
|
||
for (key, action) in shortcuts {
|
||
let entry = shortcut_spans(key, action);
|
||
let entry_width: usize = entry.iter().map(Span::width).sum();
|
||
let needed = if row.is_empty() {
|
||
entry_width
|
||
} else {
|
||
row_width + 1 + entry_width
|
||
};
|
||
if !row.is_empty() && needed > width {
|
||
rows.push(Line::from(std::mem::take(&mut row)));
|
||
if rows.len() == SHORTCUT_ROWS_MAX {
|
||
return rows;
|
||
}
|
||
row_width = entry_width;
|
||
} else {
|
||
if !row.is_empty() {
|
||
row.push(Span::raw(" "));
|
||
}
|
||
row_width = needed;
|
||
}
|
||
row.extend(entry);
|
||
}
|
||
|
||
rows.push(Line::from(row));
|
||
rows
|
||
}
|
||
|
||
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),
|
||
(DownloadStatus::Cancelled, GLYPH_FAILED, COLOR_INACTIVE),
|
||
] {
|
||
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));
|
||
}
|
||
|
||
fn texts(rows: &[Line<'static>]) -> Vec<String> {
|
||
rows.iter()
|
||
.map(|line| line.spans.iter().map(|s| s.content.as_ref()).collect())
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn shortcuts_stay_on_one_row_when_they_fit() {
|
||
let rows = pack_shortcuts(&[("q", "quit"), ("s", "search")], 80);
|
||
assert_eq!(texts(&rows), ["[q → quit] [s → search]"]);
|
||
}
|
||
|
||
#[test]
|
||
fn shortcuts_wrap_between_entries_never_inside_one() {
|
||
let rows = pack_shortcuts(
|
||
&[("Space", "select"), ("Enter", "download"), ("q", "quit")],
|
||
30,
|
||
);
|
||
// "[Space → select] [Enter → download]" is 35 cells, over the 30 the
|
||
// bar has, so the second entry starts a new row whole.
|
||
assert_eq!(
|
||
texts(&rows),
|
||
["[Space → select]", "[Enter → download] [q → quit]"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_entry_wider_than_the_bar_still_gets_a_row() {
|
||
let rows = pack_shortcuts(&[("Enter", "download"), ("q", "quit")], 5);
|
||
assert_eq!(texts(&rows), ["[Enter → download]", "[q → quit]"]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_bar_never_grows_past_its_cap() {
|
||
let keys: Vec<(&str, &str)> = (0..10).map(|_| ("k", "act")).collect();
|
||
let rows = pack_shortcuts(&keys, 12);
|
||
assert_eq!(rows.len(), SHORTCUT_ROWS_MAX);
|
||
assert_eq!(texts(&rows)[0], "[k → act]");
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|