use std::time::Duration;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
#[derive(Debug, Clone)]
pub struct Theme {
pub name: &'static str,
pub bg: Color,
pub panel_bg: Color,
pub sidebar_bg: Color,
pub accent: Color,
pub subtle: Color,
pub text: Color,
pub text_dim: Color,
pub now_playing: Color,
pub danger: Color,
pub dir_col: Color,
pub vol_empty: Color,
pub transparent: bool,
pub ascii: bool,
}
#[derive(Debug)]
pub struct Glyphs {
pub divider: &'static str,
pub marker: &'static str,
pub arrow_left: &'static str,
pub arrow_right: &'static str,
pub bar_fill: &'static str,
pub bar_empty: &'static str,
pub sep: &'static str,
pub note: &'static str,
pub folder: &'static str,
pub times: &'static str,
pub rule: &'static str,
pub track: &'static str,
pub thumb: &'static str,
pub eq: [&'static str; 4],
pub eq_idle: &'static str,
}
static UNICODE_GLYPHS: Glyphs = Glyphs {
divider: "│",
marker: "▶ ",
arrow_left: "◀",
arrow_right: "▶",
bar_fill: "█",
bar_empty: "░",
sep: " · ",
note: "♪",
folder: "📁",
times: "×",
rule: "─",
track: "━",
thumb: "●",
eq: ["▁▃▅", "▃▅▂", "▅▂▆", "▂▆▃"],
eq_idle: "▁▁▁",
};
static ASCII_GLYPHS: Glyphs = Glyphs {
divider: "|",
marker: "> ",
arrow_left: "<",
arrow_right: ">",
bar_fill: "#",
bar_empty: "-",
sep: " - ",
note: "*",
folder: "[/]",
times: "x",
rule: "-",
track: "=",
thumb: "O",
eq: [".oO", "oO.", "O.o", ".Oo"],
eq_idle: "...",
};
impl Theme {
pub const fn glyphs(&self) -> &'static Glyphs {
if self.ascii {
&ASCII_GLYPHS
} else {
&UNICODE_GLYPHS
}
}
fn maybe_color(&self, color: Color) -> Option<Color> {
(!self.transparent).then_some(color)
}
fn apply_color(&self, style: Style, color: Color) -> Style {
self.maybe_color(color).map_or(style, |c| style.bg(c))
}
pub fn apply_bg(&self, style: Style) -> Style {
self.apply_color(style, self.bg)
}
pub fn apply_panel_bg(&self, style: Style) -> Style {
self.apply_color(style, self.panel_bg)
}
pub fn apply_sidebar_bg(&self, style: Style) -> Style {
self.apply_color(style, self.sidebar_bg)
}
pub fn selection_style(&self) -> Style {
if self.ascii {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
.fg(self.text)
.bg(self.panel_bg)
.add_modifier(Modifier::BOLD)
}
}
}
pub fn themes() -> &'static [Theme] {
&THEMES
}
pub fn theme_by_name(name: &str) -> &'static Theme {
THEMES.iter().find(|t| t.name == name).unwrap_or(&THEMES[0])
}
pub fn console_themes() -> &'static [Theme] {
&CONSOLE_THEMES
}
pub fn console_theme_by_name(name: &str) -> &'static Theme {
CONSOLE_THEMES
.iter()
.find(|t| t.name == name)
.unwrap_or(&CONSOLE_THEMES[0])
}
static CONSOLE_THEMES: [Theme; 2] = [
Theme {
name: "native",
bg: Color::Reset,
panel_bg: Color::Reset,
sidebar_bg: Color::Reset,
accent: Color::LightCyan, subtle: Color::Gray, text: Color::White, text_dim: Color::Gray, now_playing: Color::LightGreen, danger: Color::LightRed, dir_col: Color::LightYellow, vol_empty: Color::Gray, transparent: false,
ascii: true,
},
Theme {
name: "paper",
bg: Color::White,
panel_bg: Color::White,
sidebar_bg: Color::White,
accent: Color::Blue, subtle: Color::DarkGray, text: Color::Black, text_dim: Color::DarkGray, now_playing: Color::Magenta, danger: Color::Red, dir_col: Color::Yellow, vol_empty: Color::DarkGray, transparent: false,
ascii: true,
},
];
static THEMES: [Theme; 15] = [
Theme {
name: "dark",
bg: Color::Rgb(18, 18, 18),
panel_bg: Color::Rgb(24, 24, 24),
sidebar_bg: Color::Rgb(18, 18, 18),
accent: Color::Rgb(100, 180, 255),
subtle: Color::Rgb(80, 80, 80),
text: Color::White,
text_dim: Color::Rgb(179, 179, 179),
now_playing: Color::Rgb(100, 180, 255),
danger: Color::Rgb(255, 80, 80),
dir_col: Color::Rgb(255, 210, 100),
vol_empty: Color::Rgb(50, 50, 50),
transparent: false,
ascii: false,
},
Theme {
name: "light",
bg: Color::Rgb(245, 245, 245),
panel_bg: Color::Rgb(235, 235, 235),
sidebar_bg: Color::Rgb(240, 240, 240),
accent: Color::Rgb(0, 100, 210),
subtle: Color::Rgb(160, 160, 160),
text: Color::Rgb(20, 20, 20),
text_dim: Color::Rgb(90, 90, 90),
now_playing: Color::Rgb(0, 100, 210),
danger: Color::Rgb(200, 30, 30),
dir_col: Color::Rgb(180, 100, 0),
vol_empty: Color::Rgb(200, 200, 200),
transparent: false,
ascii: false,
},
Theme {
name: "nord",
bg: Color::Rgb(46, 52, 64),
panel_bg: Color::Rgb(59, 66, 82),
sidebar_bg: Color::Rgb(46, 52, 64),
accent: Color::Rgb(136, 192, 208),
subtle: Color::Rgb(76, 86, 106),
text: Color::Rgb(236, 239, 244),
text_dim: Color::Rgb(216, 222, 233),
now_playing: Color::Rgb(163, 190, 140),
danger: Color::Rgb(191, 97, 106),
dir_col: Color::Rgb(235, 203, 139),
vol_empty: Color::Rgb(67, 76, 94),
transparent: false,
ascii: false,
},
Theme {
name: "gruvbox",
bg: Color::Rgb(40, 40, 40),
panel_bg: Color::Rgb(50, 48, 47),
sidebar_bg: Color::Rgb(40, 40, 40),
accent: Color::Rgb(250, 189, 47),
subtle: Color::Rgb(102, 92, 84),
text: Color::Rgb(235, 219, 178),
text_dim: Color::Rgb(189, 174, 147),
now_playing: Color::Rgb(184, 187, 38),
danger: Color::Rgb(251, 73, 52),
dir_col: Color::Rgb(214, 93, 14),
vol_empty: Color::Rgb(60, 56, 54),
transparent: false,
ascii: false,
},
Theme {
name: "gruvbox_light",
bg: Color::Rgb(251, 241, 199),
panel_bg: Color::Rgb(242, 229, 188),
sidebar_bg: Color::Rgb(251, 241, 199),
accent: Color::Rgb(181, 118, 20),
subtle: Color::Rgb(168, 153, 132),
text: Color::Rgb(60, 56, 54),
text_dim: Color::Rgb(102, 92, 84),
now_playing: Color::Rgb(121, 116, 14),
danger: Color::Rgb(204, 36, 29),
dir_col: Color::Rgb(214, 93, 14),
vol_empty: Color::Rgb(213, 196, 161),
transparent: false,
ascii: false,
},
Theme {
name: "rosepine",
bg: Color::Rgb(25, 23, 36),
panel_bg: Color::Rgb(31, 29, 46),
sidebar_bg: Color::Rgb(25, 23, 36),
accent: Color::Rgb(196, 167, 231),
subtle: Color::Rgb(110, 106, 134),
text: Color::Rgb(224, 222, 244),
text_dim: Color::Rgb(144, 140, 170),
now_playing: Color::Rgb(235, 188, 186),
danger: Color::Rgb(235, 111, 146),
dir_col: Color::Rgb(246, 193, 119),
vol_empty: Color::Rgb(38, 35, 58),
transparent: false,
ascii: false,
},
Theme {
name: "rosepine_dawn",
bg: Color::Rgb(250, 244, 237),
panel_bg: Color::Rgb(255, 250, 243),
sidebar_bg: Color::Rgb(250, 244, 237),
accent: Color::Rgb(144, 122, 169),
subtle: Color::Rgb(152, 147, 165),
text: Color::Rgb(87, 82, 121),
text_dim: Color::Rgb(121, 117, 147),
now_playing: Color::Rgb(180, 99, 122),
danger: Color::Rgb(180, 99, 122),
dir_col: Color::Rgb(234, 157, 52),
vol_empty: Color::Rgb(223, 218, 217),
transparent: false,
ascii: false,
},
Theme {
name: "catppuccin",
bg: Color::Rgb(30, 30, 46),
panel_bg: Color::Rgb(36, 36, 54),
sidebar_bg: Color::Rgb(30, 30, 46),
accent: Color::Rgb(137, 180, 250),
subtle: Color::Rgb(88, 91, 112),
text: Color::Rgb(205, 214, 244),
text_dim: Color::Rgb(166, 173, 200),
now_playing: Color::Rgb(166, 227, 161),
danger: Color::Rgb(243, 139, 168),
dir_col: Color::Rgb(249, 226, 175),
vol_empty: Color::Rgb(49, 50, 68),
transparent: false,
ascii: false,
},
Theme {
name: "catppuccin_latte",
bg: Color::Rgb(239, 241, 245),
panel_bg: Color::Rgb(230, 233, 239),
sidebar_bg: Color::Rgb(239, 241, 245),
accent: Color::Rgb(30, 102, 245),
subtle: Color::Rgb(172, 176, 190),
text: Color::Rgb(76, 79, 105),
text_dim: Color::Rgb(108, 111, 133),
now_playing: Color::Rgb(64, 160, 43),
danger: Color::Rgb(210, 15, 57),
dir_col: Color::Rgb(223, 142, 29),
vol_empty: Color::Rgb(204, 208, 218),
transparent: false,
ascii: false,
},
Theme {
name: "dracula",
bg: Color::Rgb(40, 42, 54),
panel_bg: Color::Rgb(48, 50, 65),
sidebar_bg: Color::Rgb(40, 42, 54),
accent: Color::Rgb(189, 147, 249),
subtle: Color::Rgb(98, 114, 164),
text: Color::Rgb(248, 248, 242),
text_dim: Color::Rgb(191, 192, 190),
now_playing: Color::Rgb(80, 250, 123),
danger: Color::Rgb(255, 85, 85),
dir_col: Color::Rgb(255, 184, 108),
vol_empty: Color::Rgb(55, 57, 72),
transparent: false,
ascii: false,
},
Theme {
name: "tokyo_night",
bg: Color::Rgb(26, 27, 38),
panel_bg: Color::Rgb(31, 35, 53),
sidebar_bg: Color::Rgb(26, 27, 38),
accent: Color::Rgb(122, 162, 247),
subtle: Color::Rgb(86, 95, 137),
text: Color::Rgb(192, 202, 245),
text_dim: Color::Rgb(169, 177, 214),
now_playing: Color::Rgb(158, 206, 106),
danger: Color::Rgb(247, 118, 142),
dir_col: Color::Rgb(224, 175, 104),
vol_empty: Color::Rgb(41, 46, 66),
transparent: false,
ascii: false,
},
Theme {
name: "solarized_dark",
bg: Color::Rgb(0, 43, 54),
panel_bg: Color::Rgb(7, 54, 66),
sidebar_bg: Color::Rgb(0, 43, 54),
accent: Color::Rgb(38, 139, 210),
subtle: Color::Rgb(88, 110, 117),
text: Color::Rgb(253, 246, 227),
text_dim: Color::Rgb(147, 161, 161),
now_playing: Color::Rgb(133, 153, 0),
danger: Color::Rgb(220, 50, 47),
dir_col: Color::Rgb(203, 75, 22),
vol_empty: Color::Rgb(0, 35, 43),
transparent: false,
ascii: false,
},
Theme {
name: "solarized_light",
bg: Color::Rgb(253, 246, 227),
panel_bg: Color::Rgb(238, 232, 213),
sidebar_bg: Color::Rgb(253, 246, 227),
accent: Color::Rgb(38, 139, 210),
subtle: Color::Rgb(147, 161, 161),
text: Color::Rgb(0, 43, 54),
text_dim: Color::Rgb(88, 110, 117),
now_playing: Color::Rgb(133, 153, 0),
danger: Color::Rgb(220, 50, 47),
dir_col: Color::Rgb(203, 75, 22),
vol_empty: Color::Rgb(210, 203, 186),
transparent: false,
ascii: false,
},
Theme {
name: "everforest",
bg: Color::Rgb(35, 38, 33),
panel_bg: Color::Rgb(43, 47, 40),
sidebar_bg: Color::Rgb(35, 38, 33),
accent: Color::Rgb(131, 192, 146),
subtle: Color::Rgb(93, 98, 86),
text: Color::Rgb(211, 198, 170),
text_dim: Color::Rgb(157, 151, 132),
now_playing: Color::Rgb(131, 192, 146),
danger: Color::Rgb(230, 126, 128),
dir_col: Color::Rgb(230, 192, 115),
vol_empty: Color::Rgb(53, 57, 49),
transparent: false,
ascii: false,
},
Theme {
name: "kanagawa",
bg: Color::Rgb(22, 22, 29),
panel_bg: Color::Rgb(31, 31, 40),
sidebar_bg: Color::Rgb(22, 22, 29),
accent: Color::Rgb(126, 156, 216),
subtle: Color::Rgb(84, 84, 109),
text: Color::Rgb(220, 215, 186),
text_dim: Color::Rgb(150, 147, 125),
now_playing: Color::Rgb(118, 148, 106),
danger: Color::Rgb(195, 95, 95),
dir_col: Color::Rgb(196, 154, 105),
vol_empty: Color::Rgb(38, 38, 50),
transparent: false,
ascii: false,
},
];
pub fn cursor_spans(value: &str, cursor: usize, theme: &Theme) -> Vec<Span<'static>> {
let text = Style::default().fg(theme.text);
let block = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::REVERSED);
let before = &value[..cursor];
let after = &value[cursor..];
let (under, rest) = after.chars().next().map_or_else(
|| (" ".to_string(), ""),
|c| (c.to_string(), &after[c.len_utf8()..]),
);
vec![
Span::styled(before.to_string(), text),
Span::styled(under, block),
Span::styled(rest.to_string(), text),
]
}
pub fn cursor_spans_windowed(
value: &str,
cursor: usize,
width: usize,
theme: &Theme,
) -> Vec<Span<'static>> {
let start = h_window_start(value, cursor, width);
let window = h_window(value, start, width);
let cur = value[..cursor].chars().count();
let window_cursor = window
.char_indices()
.nth(cur.saturating_sub(start))
.map_or(window.len(), |(i, _)| i);
cursor_spans(&window, window_cursor, theme)
}
pub fn h_window_start(value: &str, cursor: usize, width: usize) -> usize {
if width == 0 {
return 0;
}
let chars: Vec<char> = value.chars().collect();
let total: usize = chars.iter().map(|c| char_cols(*c)).sum::<usize>() + 1;
if total <= width {
return 0;
}
let cur = value[..cursor].chars().count();
let cursor_cell = chars.get(cur).map_or(1, |c| char_cols(*c));
let mut used = cursor_cell;
let mut start = cur;
while start > 0 {
let w = char_cols(chars[start - 1]);
if used + w > width {
break;
}
used += w;
start -= 1;
}
start
}
pub fn h_window(line: &str, start: usize, width: usize) -> String {
let mut out = String::new();
let mut used = 0;
for c in line.chars().skip(start) {
let w = char_cols(c);
if used + w > width {
break;
}
out.push(c);
used += w;
}
out
}
fn char_cols(c: char) -> usize {
UnicodeWidthChar::width(c).unwrap_or(0)
}
pub fn render_empty_state(
frame: &mut Frame<'_>,
area: Rect,
headline: &str,
key: &str,
action: &str,
t: &Theme,
) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0),
])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(
headline.to_string(),
Style::default().fg(t.text_dim),
))
.alignment(Alignment::Center),
rows[1],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("press ", Style::default().fg(t.subtle)),
Span::styled(key.to_string(), Style::default().fg(t.accent)),
Span::styled(format!(" {action}"), Style::default().fg(t.subtle)),
]))
.alignment(Alignment::Center),
rows[2],
);
}
pub const NUM_W: usize = 3;
const GAP: usize = 2;
pub const GAP_S: &str = " ";
pub struct Columns {
title: usize,
artist: usize,
album: usize,
time: usize,
}
impl Columns {
const MIN_TITLE: usize = 16;
const MIN_META: usize = 10;
const TIME_W: usize = 5;
pub const fn for_width(width: usize) -> Self {
let body = width.saturating_sub(NUM_W + GAP);
let meta2 = (GAP + Self::MIN_META) * 2;
let time = GAP + Self::TIME_W;
if body >= Self::MIN_TITLE + meta2 + time {
let rest = body - time - GAP * 2;
let artist = rest * 3 / 10;
let album = rest * 3 / 10;
Self {
title: rest - artist - album,
artist,
album,
time: Self::TIME_W,
}
} else if body >= Self::MIN_TITLE + GAP + Self::MIN_META + time {
let rest = body - time;
let title = rest * 3 / 5;
Self {
title,
artist: rest - title - GAP,
album: 0,
time: Self::TIME_W,
}
} else if body >= Self::MIN_TITLE + GAP + Self::MIN_META {
let title = body * 3 / 5;
Self {
title,
artist: body - title - GAP,
album: 0,
time: 0,
}
} else {
Self {
title: body,
artist: 0,
album: 0,
time: 0,
}
}
}
pub fn cells(&self, title: &str, artist: &str, album: &str, time: &str) -> Vec<Span<'static>> {
let mut out = vec![Span::raw(pad(title, self.title))];
if self.artist > 0 {
out.push(Span::raw(format!("{GAP_S}{}", pad(artist, self.artist))));
}
if self.album > 0 {
out.push(Span::raw(format!("{GAP_S}{}", pad(album, self.album))));
}
if self.time > 0 {
out.push(Span::raw(format!(
"{GAP_S}{time:>w$}",
w = self.time.max(str_width(time))
)));
}
out
}
}
fn pad(s: &str, width: usize) -> String {
let s = truncate(s, width);
let fill = width.saturating_sub(str_width(&s));
format!("{s}{blank:fill$}", blank = "")
}
pub fn row_marker(is_playing: bool, paused: bool, elapsed: Duration, t: &Theme) -> String {
if !is_playing {
return String::new();
}
let g = t.glyphs();
if paused {
return g.eq_idle.to_string();
}
let frame = (elapsed.as_millis() / 250) as usize % g.eq.len();
g.eq[frame].to_string()
}
pub fn styled_block<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(if focused { theme.accent } else { theme.subtle }))
.title_style(
Style::default()
.fg(if focused {
theme.accent
} else {
theme.text_dim
})
.add_modifier(Modifier::BOLD),
)
}
pub fn str_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub fn truncate(s: &str, max: usize) -> String {
if str_width(s) <= max {
return s.to_string();
}
if max == 0 {
return String::new();
}
let mut out = String::new();
let mut used = 0;
for c in s.chars() {
let w = UnicodeWidthChar::width(c).unwrap_or(0);
if used + w > max - 1 {
break;
}
out.push(c);
used += w;
}
out.push('~');
out
}
pub fn format_duration(secs: u64) -> String {
let m = secs / 60;
let s = secs % 60;
format!("{m}:{s:02}")
}