From bf11685a03988282df47860e4821f4506965df94 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Sun, 11 Oct 2026 18:39:28 +0200 Subject: [PATCH] add: accent color option in config --- src/app.rs | 2 + src/config.rs | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- src/ui.rs | 78 ++++++++++++++++++------------------- 3 files changed, 141 insertions(+), 44 deletions(-) diff --git a/src/app.rs b/src/app.rs index e8e2ad9..078d9ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -165,6 +165,7 @@ pub struct App { pub volume: u8, pub shuffle: bool, pub repeat: RepeatMode, + pub accent: ratatui::style::Color, played: HashSet, fail_streak: u8, result_cursor: Option, @@ -241,6 +242,7 @@ impl App { volume: p.volume, shuffle: p.shuffle, repeat: p.repeat, + accent: cfg.ui.accent, played: HashSet::new(), fail_streak: 0, result_cursor: None, diff --git a/src/config.rs b/src/config.rs index 8e98c62..296d890 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,33 @@ use std::fs; use std::path::PathBuf; +use std::str::FromStr; use anyhow::Result; -use serde::Deserialize; +use ratatui::style::Color; +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; +fn deserialize_color<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct ColorVisitor; + impl<'de> Visitor<'de> for ColorVisitor { + type Value = Color; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "a color name (e.g. \"cyan\") or hex value (e.g. \"#FF8800\")") + } + fn visit_str(self, v: &str) -> Result { + Color::from_str(v).map_err(de::Error::custom) + } + } + deserializer.deserialize_str(ColorVisitor) +} + #[derive(Debug, Clone, Copy, Deserialize)] #[serde(default)] pub struct Playback { @@ -54,11 +74,29 @@ pub struct Artist { pub popular_first: bool, } +fn default_accent() -> Color { + Color::Cyan +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(default)] +pub struct Ui { + #[serde(deserialize_with = "deserialize_color")] + pub accent: Color, +} + +impl Default for Ui { + fn default() -> Self { + Ui { accent: default_accent() } + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct Config { pub playback: Playback, pub artist: Artist, + pub ui: Ui, } impl Config { @@ -66,7 +104,27 @@ impl Config { Ok(Store::dir()?.join("config.toml")) } - pub fn render(p: &Playback, a: &Artist) -> String { + pub fn render(p: &Playback, a: &Artist, u: &Ui) -> String { + let accent = match u.accent { + Color::Reset => "reset", + Color::Black => "black", + Color::Red => "red", + Color::Green => "green", + Color::Yellow => "yellow", + Color::Blue => "blue", + Color::Magenta => "magenta", + Color::Cyan => "cyan", + Color::Gray => "gray", + Color::DarkGray => "dark-gray", + Color::LightRed => "light-red", + Color::LightGreen => "light-green", + Color::LightYellow => "light-yellow", + Color::LightBlue => "light-blue", + Color::LightMagenta => "light-magenta", + Color::LightCyan => "light-cyan", + Color::White => "white", + _ => "cyan", + }; format!( "\ # Drift settings. Drift only reads this file and never rewrites it, so your @@ -84,6 +142,14 @@ volume_step = {vstep:?} [artist] # :artist and ga list the artist's most popular songs first, then more of their songs below popular_first = {popular} + +[ui] +# Accent color used throughout the UI. +# Named colors: black, red, green, yellow, blue, magenta, cyan, gray, dark-gray, +# light-red, light-green, light-yellow, light-blue, light-magenta, +# light-cyan, white +# Or use a hex value: \"#FF8800\" +accent = \"{accent}\" ", volume = p.volume, shuffle = p.shuffle, @@ -93,6 +159,7 @@ popular_first = {popular} seek = p.seek_step, vstep = p.volume_step, popular = a.popular_first, + accent = accent, ) } @@ -107,7 +174,7 @@ popular_first = {popular} let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = Self::write(&Self::render(&Playback::default(), &Artist::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default())); return (Config::default(), None); } Err(e) => { @@ -186,7 +253,7 @@ pub fn migrate_legacy() { let _ = Session::write(&s); } } - let _ = Config::write(&Config::render(&p, &Artist::default())); + let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default())); } #[cfg(test)] @@ -195,7 +262,7 @@ mod tests { #[test] fn default_file_parses_back() { - let text = Config::render(&Playback::default(), &Artist::default()); + let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default()); let c: Config = toml::from_str(&text).unwrap(); let d = Playback::default(); assert_eq!(c.playback.volume, d.volume); @@ -238,4 +305,32 @@ mod tests { p.sanitize(); assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); } + + #[test] + fn accent_color_parsing() { + // Default is cyan + let c: Config = toml::from_str("").unwrap(); + assert_eq!(c.ui.accent, Color::Cyan); + + // Named colors + let c: Config = toml::from_str("[ui]\naccent = \"red\"").unwrap(); + assert_eq!(c.ui.accent, Color::Red); + let c: Config = toml::from_str("[ui]\naccent = \"green\"").unwrap(); + assert_eq!(c.ui.accent, Color::Green); + let c: Config = toml::from_str("[ui]\naccent = \"magenta\"").unwrap(); + assert_eq!(c.ui.accent, Color::Magenta); + + // Hex value + let c: Config = toml::from_str("[ui]\naccent = \"#FF8800\"").unwrap(); + assert_eq!(c.ui.accent, Color::Rgb(255, 136, 0)); + + // Invalid color is an error + assert!(toml::from_str::("[ui]\naccent = \"notacolor\"").is_err()); + + // Roundtrip: render then parse + let u = Ui { accent: Color::Blue }; + let text = Config::render(&Playback::default(), &Artist::default(), &u); + let c: Config = toml::from_str(&text).unwrap(); + assert_eq!(c.ui.accent, Color::Blue); + } } diff --git a/src/ui.rs b/src/ui.rs index 9c9ea49..c00d787 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,13 +7,12 @@ use ratatui::Frame; use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY}; use crate::model::{fmt_time, Track}; -const ACCENT: Color = Color::Cyan; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -fn selected_style() -> Style { +fn selected_style(accent: Color) -> Style { Style::new() .fg(Color::Black) - .bg(ACCENT) + .bg(accent) .add_modifier(Modifier::BOLD) } @@ -87,7 +86,7 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { .areas(area); f.render_widget( - Paragraph::new(" Drift").style(Style::new().fg(ACCENT).bold()), + Paragraph::new(" Drift").style(Style::new().fg(app.accent).bold()), name, ); @@ -100,7 +99,7 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { Tabs::new(titles) .select(app.view.idx()) .style(Style::new().fg(Color::Gray)) - .highlight_style(Style::new().fg(Color::Black).bg(ACCENT).bold()) + .highlight_style(Style::new().fg(Color::Black).bg(app.accent).bold()) .divider(" "), tabs, ); @@ -167,7 +166,7 @@ fn track_rows<'a>( .collect() } -fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Table<'a> { +fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>, accent: Color) -> Table<'a> { let mut header = vec!["", " #", "Title", "Artist"]; let mut widths = vec![ Constraint::Length(2), @@ -186,7 +185,7 @@ fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Ta .header(Row::new(header).style(header_style())) .block(block) .column_spacing(1) - .row_highlight_style(selected_style()) + .row_highlight_style(selected_style(accent)) } fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { @@ -210,7 +209,7 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { }; let mut block = Block::bordered() .title(format!("{title}({}) ", tracks.len())) - .border_style(Style::new().fg(ACCENT)); + .border_style(Style::new().fg(app.accent)); if !tracks.is_empty() { let keys: &[(&str, &str)] = if view == View::Queue { &[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")] @@ -239,7 +238,7 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { return empty_hint(f, block, area, hint); } let rows = track_rows(tracks, playing, show_album); - let table = track_table(rows, show_album, block); + let table = track_table(rows, show_album, block, app.accent); let idx = view.idx(); f.render_stateful_widget(table, area, &mut app.states[idx]); } @@ -253,7 +252,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { }; let mut block = Block::bordered() .title(format!(" {name} ({}) ", tracks.len())) - .border_style(Style::new().fg(ACCENT)); + .border_style(Style::new().fg(app.accent)); if !tracks.is_empty() { let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")]; if let Some(h) = hints(&keys, area.width) { @@ -274,7 +273,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { } let show_album = area.width >= 76; let rows = track_rows(tracks, playing, show_album); - let table = track_table(rows, show_album, block); + let table = track_table(rows, show_album, block, app.accent); f.render_stateful_widget(table, area, &mut app.pl_state); } @@ -289,7 +288,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { let mut block = Block::bordered() .title(format!(" Playlists ({}) ", app.store.playlists.len())) - .border_style(Style::new().fg(ACCENT)); + .border_style(Style::new().fg(app.accent)); if !app.store.playlists.is_empty() { if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) { block = block.title_bottom(h); @@ -336,7 +335,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { ) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .block(block) - .row_highlight_style(selected_style()); + .row_highlight_style(selected_style(app.accent)); f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); let sel = app.selected(); @@ -348,7 +347,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { Some(tracks) if !tracks.is_empty() => { let show_album = right.width >= 70; let rows = track_rows(tracks, None, show_album); - f.render_widget(track_table(rows, show_album, preview_block), right); + f.render_widget(track_table(rows, show_album, preview_block, app.accent), right); } _ => f.render_widget(preview_block, right), } @@ -356,8 +355,8 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; -fn gradient(_t: f64) -> Color { - ACCENT +fn gradient(_t: f64, accent: Color) -> Color { + accent } fn equalizer(app: &App, n: usize) -> Vec> { @@ -373,7 +372,7 @@ fn equalizer(app: &App, n: usize) -> Vec> { _ => 0, }; let color = if app.play_state == PlayState::Playing { - gradient(x / n as f64) + gradient(x / n as f64, app.accent) } else { Color::DarkGray }; @@ -386,10 +385,10 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { let active = app.play_state != PlayState::Stopped; let mut block = Block::bordered() .border_type(BorderType::Rounded) - .border_style(Style::new().fg(if active { ACCENT } else { Color::DarkGray })) + .border_style(Style::new().fg(if active { app.accent } else { Color::DarkGray })) .title(Line::from(vec![ - Span::styled(" ♪ ", Style::new().fg(ACCENT).bold()), - Span::styled("Now Playing ", Style::new().fg(ACCENT).bold()), + Span::styled(" ♪ ", Style::new().fg(app.accent).bold()), + Span::styled("Now Playing ", Style::new().fg(app.accent).bold()), ])); let mut pending = String::new(); @@ -410,7 +409,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { f.render_widget(block, area); let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - let on = Style::new().fg(ACCENT).bold(); + let on = Style::new().fg(app.accent).bold(); let vol_cells = 5usize; let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100; let mut flags = vec![ @@ -432,7 +431,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { ]; for i in 0..vol_cells { flags.push(if i < vol_fill.min(vol_cells) { - Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64))) + Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64, app.accent))) } else { Span::styled("─", dim()) }); @@ -466,7 +465,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { (Some(t), true) => { spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold())); spans.push(Span::styled(" · ", dim())); - spans.push(Span::styled(t.artist.clone(), Style::new().fg(ACCENT))); + spans.push(Span::styled(t.artist.clone(), Style::new().fg(app.accent))); } (Some(t), false) => spans.push(Span::styled( format!("Last played: {} — {}", t.title, t.artist), @@ -497,7 +496,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { line.push(if !active || app.duration <= 0.0 { Span::styled("─", dim()) } else if i < head { - Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64))) + Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64, app.accent))) } else if i == head { Span::styled("●", Style::new().fg(Color::White).bold()) } else { @@ -580,8 +579,8 @@ fn draw_info(f: &mut Frame, app: &App, area: Rect) { Paragraph::new(lines).block( Block::bordered() .border_type(BorderType::Rounded) - .border_style(Style::new().fg(ACCENT)) - .title(Span::styled(" ♪ Song info ", Style::new().fg(ACCENT).bold())) + .border_style(Style::new().fg(app.accent)) + .title(Span::styled(" ♪ Song info ", Style::new().fg(app.accent).bold())) .title_bottom(Line::styled(" any key to close ", dim()).right_aligned()), ), popup, @@ -676,11 +675,11 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) { }; let block = Block::bordered() .border_type(BorderType::Rounded) - .border_style(Style::new().fg(if search { ACCENT } else { ACCENT })) + .border_style(Style::new().fg(if search { app.accent } else { app.accent })) .title(Span::styled( title, Style::new() - .fg(if search { ACCENT } else { ACCENT }) + .fg(if search { app.accent } else { app.accent }) .bold(), )) .title_bottom(Line::styled(footer, dim()).right_aligned()); @@ -704,7 +703,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) { Paragraph::new(Line::from(vec![ Span::styled( prompt, - Style::new().fg(Color::Black).bg(if search { ACCENT } else { ACCENT }).bold(), + Style::new().fg(Color::Black).bg(if search { app.accent } else { app.accent }).bold(), ), Span::raw(" "), Span::styled(shown, Style::new().fg(Color::White)), @@ -737,7 +736,7 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) { Line::styled(" ⏎ run", dim()) } else { Line::from(vec![ - Span::styled(format!(" {first_word} "), Style::new().fg(ACCENT).bold()), + Span::styled(format!(" {first_word} "), Style::new().fg(app.accent).bold()), Span::styled(d, dim()), ]) }); @@ -752,12 +751,12 @@ fn draw_palette(f: &mut Frame, app: &App, area: Rect) { let text: String = text.chars().take(width).collect(); lines.push(if Some(i) == sel { let pad = width.saturating_sub(text.chars().count()); - Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style()) + Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(app.accent)) } else { Line::from(vec![ Span::styled( text.chars().take(11).collect::(), - Style::new().fg(ACCENT), + Style::new().fg(app.accent), ), Span::styled(text.chars().skip(11).collect::(), dim()), ]) @@ -799,14 +798,15 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) { }; let block = Block::bordered() .border_type(BorderType::Rounded) - .border_style(Style::new().fg(ACCENT)) - .title(Span::styled(title, Style::new().fg(ACCENT).bold())) + .border_style(Style::new().fg(app.accent)) + .title(Span::styled(title, Style::new().fg(app.accent).bold())) .title_bottom(Line::styled(footer, dim()).right_aligned()); let inner = block.inner(popup); f.render_widget(Clear, popup); f.render_widget(block, popup); let width = inner.width as usize; + let accent = app.accent; let lines: Vec = items .iter() .enumerate() @@ -821,7 +821,7 @@ fn draw_menu(f: &mut Frame, app: &App, area: Rect) { } let text: String = text.chars().take(width).collect(); let pad = width.saturating_sub(text.chars().count()); - return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style()); + return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style(accent)); } let danger = matches!( it.action, @@ -966,10 +966,10 @@ fn wrap_words(text: &str, width: usize) -> Vec { out } -fn help_lines(inner_w: usize) -> Vec> { +fn help_lines(inner_w: usize, accent: Color) -> Vec> { let key_w = HELP_KEY_W.min(inner_w / 2).max(1); let desc_w = inner_w.saturating_sub(key_w); - let key_style = Style::new().fg(ACCENT); + let key_style = Style::new().fg(accent); let mut lines: Vec> = Vec::new(); for (k, d) in HELP { @@ -1025,7 +1025,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { .map(|(i, name)| { let text = format!(" {} {name}", i + 1); if i == app.artist_sel { - Line::styled(text, selected_style()) + Line::styled(text, selected_style(app.accent)) } else { Line::raw(text) } @@ -1045,7 +1045,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { let w = area.width.min(80); - let lines = help_lines(w.saturating_sub(2) as usize); + let lines = help_lines(w.saturating_sub(2) as usize, app.accent); let h = (lines.len() as u16).saturating_add(2).min(area.height); let popup = Rect { x: area.x + (area.width - w) / 2,