Initial commit — ferrosonic terminal Subsonic client
Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
144
src/ui/footer.rs
Normal file
144
src/ui/footer.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! Footer bar with keybind hints and status
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Widget,
|
||||
};
|
||||
|
||||
use crate::app::state::{Notification, Page};
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
/// Footer bar widget
|
||||
pub struct Footer<'a> {
|
||||
page: Page,
|
||||
sample_rate: Option<u32>,
|
||||
notification: Option<&'a Notification>,
|
||||
colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl<'a> Footer<'a> {
|
||||
pub fn new(page: Page, colors: ThemeColors) -> Self {
|
||||
Self {
|
||||
page,
|
||||
sample_rate: None,
|
||||
notification: None,
|
||||
colors,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sample_rate(mut self, rate: Option<u32>) -> Self {
|
||||
self.sample_rate = rate;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn notification(mut self, notification: Option<&'a Notification>) -> Self {
|
||||
self.notification = notification;
|
||||
self
|
||||
}
|
||||
|
||||
fn keybinds(&self) -> Vec<(&'static str, &'static str)> {
|
||||
let mut binds = vec![
|
||||
("q", "Quit"),
|
||||
("p/Space", "Pause"),
|
||||
("h", "Prev"),
|
||||
("l", "Next"),
|
||||
("t", "Theme"),
|
||||
];
|
||||
|
||||
match self.page {
|
||||
Page::Artists => {
|
||||
binds.extend([
|
||||
("/", "Filter"),
|
||||
("←/→", "Focus"),
|
||||
("e", "Add"),
|
||||
("n", "Add next"),
|
||||
("Enter", "Play"),
|
||||
]);
|
||||
}
|
||||
Page::Queue => {
|
||||
binds.extend([
|
||||
("d", "Remove"),
|
||||
("J/K", "Move"),
|
||||
("r", "Shuffle"),
|
||||
("c", "Clear history"),
|
||||
("Enter", "Play"),
|
||||
]);
|
||||
}
|
||||
Page::Playlists => {
|
||||
binds.extend([
|
||||
("←/→", "Focus"),
|
||||
("e", "Add"),
|
||||
("n", "Add next"),
|
||||
("r", "Shuffle play"),
|
||||
("Enter", "Play"),
|
||||
]);
|
||||
}
|
||||
Page::Server => {
|
||||
binds.extend([
|
||||
("Tab", "Next field"),
|
||||
("Enter", "Test/Save"),
|
||||
("Ctrl+R", "Refresh"),
|
||||
]);
|
||||
}
|
||||
Page::Settings => {
|
||||
binds.extend([("←/→/Enter", "Change theme")]);
|
||||
}
|
||||
}
|
||||
|
||||
binds
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Footer<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||
|
||||
// Left side: keybinds or notification
|
||||
if let Some(notif) = self.notification {
|
||||
let style = if notif.is_error {
|
||||
Style::default().fg(self.colors.error)
|
||||
} else {
|
||||
Style::default().fg(self.colors.success)
|
||||
};
|
||||
buf.set_string(chunks[0].x, chunks[0].y, ¬if.message, style);
|
||||
} else {
|
||||
// Keybind hints
|
||||
let binds = self.keybinds();
|
||||
let mut spans = Vec::new();
|
||||
|
||||
for (i, (key, desc)) in binds.iter().enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(
|
||||
" │ ",
|
||||
Style::default().fg(self.colors.secondary),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(*key, Style::default().fg(self.colors.accent)));
|
||||
spans.push(Span::raw(":"));
|
||||
spans.push(Span::styled(*desc, Style::default().fg(self.colors.muted)));
|
||||
}
|
||||
|
||||
let line = Line::from(spans);
|
||||
buf.set_line(chunks[0].x, chunks[0].y, &line, chunks[0].width);
|
||||
}
|
||||
|
||||
// Right side: sample rate / status
|
||||
if let Some(rate) = self.sample_rate {
|
||||
let rate_str = format!("{}kHz", rate / 1000);
|
||||
let x = chunks[1].x + chunks[1].width.saturating_sub(rate_str.len() as u16);
|
||||
buf.set_string(
|
||||
x,
|
||||
chunks[1].y,
|
||||
&rate_str,
|
||||
Style::default().fg(self.colors.success),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/ui/header.rs
Normal file
168
src/ui/header.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! Header bar with page tabs and playback controls
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Tabs, Widget},
|
||||
};
|
||||
|
||||
use crate::app::state::{Page, PlaybackState};
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
/// Header bar widget
|
||||
pub struct Header {
|
||||
current_page: Page,
|
||||
playback_state: PlaybackState,
|
||||
colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn new(current_page: Page, playback_state: PlaybackState, colors: ThemeColors) -> Self {
|
||||
Self {
|
||||
current_page,
|
||||
playback_state,
|
||||
colors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Header {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Split header: [tabs] [playback controls]
|
||||
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||
|
||||
// Page tabs
|
||||
let titles: Vec<Line> = vec![
|
||||
Page::Artists,
|
||||
Page::Queue,
|
||||
Page::Playlists,
|
||||
Page::Server,
|
||||
Page::Settings,
|
||||
]
|
||||
.iter()
|
||||
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
|
||||
.collect();
|
||||
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(self.current_page.index())
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(self.colors.primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.divider(" │ ");
|
||||
|
||||
tabs.render(chunks[0], buf);
|
||||
|
||||
// Playback controls
|
||||
let nav_style = Style::default().fg(self.colors.muted);
|
||||
let play_style = match self.playback_state {
|
||||
PlaybackState::Playing => Style::default().fg(self.colors.accent),
|
||||
_ => Style::default().fg(self.colors.muted),
|
||||
};
|
||||
let pause_style = match self.playback_state {
|
||||
PlaybackState::Paused => Style::default().fg(self.colors.accent),
|
||||
_ => Style::default().fg(self.colors.muted),
|
||||
};
|
||||
let stop_style = match self.playback_state {
|
||||
PlaybackState::Stopped => Style::default().fg(self.colors.accent),
|
||||
_ => Style::default().fg(self.colors.muted),
|
||||
};
|
||||
|
||||
let controls = Line::from(vec![
|
||||
Span::styled(" ⏮ ", nav_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" ▶ ", play_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" ⏸ ", pause_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" ⏹ ", stop_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" ⏭ ", nav_style),
|
||||
]);
|
||||
|
||||
// Right-align controls - " ⏮ ▶ ⏸ ⏹ ⏭ " = 5*3 + 4*1 = 19
|
||||
let controls_width = 19;
|
||||
let x = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
||||
buf.set_line(x, chunks[1].y, &controls, controls_width);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clickable region in the header
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HeaderRegion {
|
||||
Tab(Page),
|
||||
PrevButton,
|
||||
PlayButton,
|
||||
PauseButton,
|
||||
StopButton,
|
||||
NextButton,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
/// Determine which region was clicked
|
||||
pub fn region_at(area: Rect, x: u16, _y: u16) -> Option<HeaderRegion> {
|
||||
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
|
||||
|
||||
if x >= chunks[0].x && x < chunks[0].x + chunks[0].width {
|
||||
// Tab area — compute actual tab positions matching Tabs widget rendering.
|
||||
// Tabs renders: [pad][title][pad] [divider] [pad][title][pad] ...
|
||||
// Default padding = 1 space each side. Divider = " │ " = 3 chars.
|
||||
let pages = [
|
||||
Page::Artists,
|
||||
Page::Queue,
|
||||
Page::Playlists,
|
||||
Page::Server,
|
||||
Page::Settings,
|
||||
];
|
||||
let divider_width: u16 = 3; // " │ "
|
||||
let padding: u16 = 1; // 1 space each side
|
||||
|
||||
let rel_x = x - chunks[0].x;
|
||||
let mut cursor: u16 = 0;
|
||||
for (i, page) in pages.iter().enumerate() {
|
||||
let label = format!("{} {}", page.shortcut(), page.label());
|
||||
let tab_width = padding + label.len() as u16 + padding;
|
||||
if rel_x >= cursor && rel_x < cursor + tab_width {
|
||||
return Some(HeaderRegion::Tab(*page));
|
||||
}
|
||||
cursor += tab_width;
|
||||
// Add divider (except after the last tab)
|
||||
if i < pages.len() - 1 {
|
||||
cursor += divider_width;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if x >= chunks[1].x && x < chunks[1].x + chunks[1].width {
|
||||
// Controls area — rendered as spans:
|
||||
// " ⏮ " + " " + " ▶ " + " " + " ⏸ " + " " + " ⏹ " + " " + " ⏭ "
|
||||
// Each button span: space + icon + space = 3 display cells (icon is 1 cell)
|
||||
// Gap spans: 1 space each
|
||||
// Total: 5*3 + 4*1 = 19 display cells
|
||||
let controls_width: u16 = 19;
|
||||
let control_start = chunks[1].x + chunks[1].width.saturating_sub(controls_width);
|
||||
if x >= control_start {
|
||||
let offset = x - control_start;
|
||||
// Layout: [0..2] ⏮ [3] gap [4..6] ▶ [7] gap [8..10] ⏸ [11] gap [12..14] ⏹ [15] gap [16..18] ⏭
|
||||
return match offset {
|
||||
0..=2 => Some(HeaderRegion::PrevButton),
|
||||
4..=6 => Some(HeaderRegion::PlayButton),
|
||||
8..=10 => Some(HeaderRegion::PauseButton),
|
||||
12..=14 => Some(HeaderRegion::StopButton),
|
||||
16..=18 => Some(HeaderRegion::NextButton),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
112
src/ui/layout.rs
Normal file
112
src/ui/layout.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
//! Main layout and rendering
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::{AppState, LayoutAreas, Page};
|
||||
|
||||
use super::footer::Footer;
|
||||
use super::header::Header;
|
||||
use super::pages;
|
||||
use super::widgets::{CavaWidget, NowPlayingWidget};
|
||||
|
||||
/// Draw the entire UI
|
||||
pub fn draw(frame: &mut Frame, state: &mut AppState) {
|
||||
let area = frame.area();
|
||||
|
||||
let cava_active = state.settings_state.cava_enabled && !state.cava_screen.is_empty();
|
||||
|
||||
// Main layout:
|
||||
// [Header] - 1 line
|
||||
// [Cava] - ~40% (optional, only when cava is active)
|
||||
// [Page Content] - flexible
|
||||
// [Now Playing] - 7 lines
|
||||
// [Footer] - 1 line
|
||||
|
||||
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Percentage(40), // Cava visualizer — top half-ish
|
||||
Constraint::Min(10), // Page content
|
||||
Constraint::Length(7), // Now playing
|
||||
Constraint::Length(1), // Footer
|
||||
])
|
||||
.split(area);
|
||||
(chunks[0], Some(chunks[1]), chunks[2], chunks[3], chunks[4])
|
||||
} else {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Min(10), // Page content
|
||||
Constraint::Length(7), // Now playing
|
||||
Constraint::Length(1), // Footer
|
||||
])
|
||||
.split(area);
|
||||
(chunks[0], None, chunks[1], chunks[2], chunks[3])
|
||||
};
|
||||
|
||||
// Compute dual-pane splits for pages that use them
|
||||
let (content_left, content_right) = match state.page {
|
||||
Page::Artists | Page::Playlists => {
|
||||
let panes = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
])
|
||||
.split(content_area);
|
||||
(Some(panes[0]), Some(panes[1]))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
// Store layout areas for mouse hit-testing
|
||||
state.layout = LayoutAreas {
|
||||
header: header_area,
|
||||
cava: cava_area,
|
||||
content: content_area,
|
||||
now_playing: now_playing_area,
|
||||
footer: footer_area,
|
||||
content_left,
|
||||
content_right,
|
||||
};
|
||||
|
||||
// Render header
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
let header = Header::new(state.page, state.now_playing.state, colors);
|
||||
frame.render_widget(header, header_area);
|
||||
|
||||
// Render cava visualizer if active
|
||||
if let Some(cava_rect) = cava_area {
|
||||
let cava_widget = CavaWidget::new(&state.cava_screen);
|
||||
frame.render_widget(cava_widget, cava_rect);
|
||||
}
|
||||
|
||||
// Render current page
|
||||
match state.page {
|
||||
Page::Artists => {
|
||||
pages::artists::render(frame, content_area, state);
|
||||
}
|
||||
Page::Queue => {
|
||||
pages::queue::render(frame, content_area, state);
|
||||
}
|
||||
Page::Playlists => {
|
||||
pages::playlists::render(frame, content_area, state);
|
||||
}
|
||||
Page::Server => {
|
||||
pages::server::render(frame, content_area, state);
|
||||
}
|
||||
Page::Settings => {
|
||||
pages::settings::render(frame, content_area, state);
|
||||
}
|
||||
}
|
||||
|
||||
// Render now playing
|
||||
let now_playing = NowPlayingWidget::new(&state.now_playing, colors);
|
||||
frame.render_widget(now_playing, now_playing_area);
|
||||
|
||||
// Render footer
|
||||
let footer = Footer::new(state.page, colors)
|
||||
.sample_rate(state.now_playing.sample_rate)
|
||||
.notification(state.notification.as_ref());
|
||||
frame.render_widget(footer, footer_area);
|
||||
}
|
||||
10
src/ui/mod.rs
Normal file
10
src/ui/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Terminal UI module
|
||||
|
||||
pub mod footer;
|
||||
pub mod header;
|
||||
pub mod layout;
|
||||
pub mod pages;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
pub use layout::draw;
|
||||
272
src/ui/pages/artists.rs
Normal file
272
src/ui/pages/artists.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
//! Artists page with tree browser and song list
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
use crate::subsonic::models::{Album, Artist};
|
||||
|
||||
|
||||
/// A tree item - either an artist or an album
|
||||
#[derive(Clone)]
|
||||
pub enum TreeItem {
|
||||
Artist { artist: Artist, expanded: bool },
|
||||
Album { album: Album },
|
||||
}
|
||||
|
||||
/// Build flattened tree items from state
|
||||
pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
|
||||
let artists = &state.artists;
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Filter artists by name
|
||||
let filtered_artists: Vec<_> = if artists.filter.is_empty() {
|
||||
artists.artists.iter().collect()
|
||||
} else {
|
||||
let filter_lower = artists.filter.to_lowercase();
|
||||
artists
|
||||
.artists
|
||||
.iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter_lower))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for artist in filtered_artists {
|
||||
let is_expanded = artists.expanded.contains(&artist.id);
|
||||
items.push(TreeItem::Artist {
|
||||
artist: artist.clone(),
|
||||
expanded: is_expanded,
|
||||
});
|
||||
|
||||
// If expanded, add albums sorted by year (oldest first)
|
||||
if is_expanded {
|
||||
if let Some(albums) = artists.albums_cache.get(&artist.id) {
|
||||
let mut sorted_albums: Vec<Album> = albums.iter().cloned().collect();
|
||||
sorted_albums.sort_by(|a, b| {
|
||||
// Albums with no year go last
|
||||
match (a.year, b.year) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(y1), Some(y2)) => std::cmp::Ord::cmp(&y1, &y2),
|
||||
}
|
||||
});
|
||||
for album in sorted_albums {
|
||||
items.push(TreeItem::Album { album });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Render the artists page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
// Split into two panes: [Tree Browser] [Song List]
|
||||
let chunks =
|
||||
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
|
||||
|
||||
render_tree(frame, chunks[0], state, &colors);
|
||||
render_songs(frame, chunks[1], state, &colors);
|
||||
}
|
||||
|
||||
/// Render the artist/album tree
|
||||
fn render_tree(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let artists = &state.artists;
|
||||
|
||||
let focused = artists.focus == 0;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let title = if artists.filter_active {
|
||||
format!(" Artists (/{}) ", artists.filter)
|
||||
} else if !artists.filter.is_empty() {
|
||||
format!(" Artists [{}] ", artists.filter)
|
||||
} else {
|
||||
" Artists ".to_string()
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style);
|
||||
|
||||
let tree_items = build_tree_items(state);
|
||||
|
||||
// Build list items from tree
|
||||
let items: Vec<ListItem> = tree_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let is_selected = Some(i) == artists.selected_index;
|
||||
|
||||
match item {
|
||||
TreeItem::Artist {
|
||||
artist,
|
||||
expanded: _,
|
||||
} => {
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.artist)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.artist)
|
||||
};
|
||||
|
||||
ListItem::new(artist.name.clone()).style(style)
|
||||
}
|
||||
TreeItem::Album { album } => {
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.album)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.album)
|
||||
};
|
||||
|
||||
// Indent albums with tree-style connector, show year in brackets
|
||||
let year_str = album.year.map(|y| format!(" [{}]", y)).unwrap_or_default();
|
||||
let text = format!(" └─ {}{}", album.name, year_str);
|
||||
|
||||
ListItem::new(text).style(style)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(state.artists.selected_index);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.artists.tree_scroll_offset = list_state.offset();
|
||||
}
|
||||
|
||||
/// Render the song list for selected album
|
||||
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let artists = &state.artists;
|
||||
|
||||
let focused = artists.focus == 1;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let title = if !artists.songs.is_empty() {
|
||||
if let Some(album) = artists.songs.first().and_then(|s| s.album.as_ref()) {
|
||||
format!(" {} ({}) ", album, artists.songs.len())
|
||||
} else {
|
||||
format!(" Songs ({}) ", artists.songs.len())
|
||||
}
|
||||
} else {
|
||||
" Songs ".to_string()
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style);
|
||||
|
||||
if artists.songs.is_empty() {
|
||||
let hint = Paragraph::new("Select an album to view songs")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if album has multiple discs
|
||||
let has_multiple_discs = artists
|
||||
.songs
|
||||
.iter()
|
||||
.any(|s| s.disc_number.map(|d| d > 1).unwrap_or(false));
|
||||
|
||||
// Build song list items
|
||||
let items: Vec<ListItem> = artists
|
||||
.songs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, song)| {
|
||||
let is_selected = Some(i) == artists.selected_song;
|
||||
let is_playing = state
|
||||
.current_song()
|
||||
.map(|s| s.id == song.id)
|
||||
.unwrap_or(false);
|
||||
|
||||
let indicator = if is_playing { "▶ " } else { " " };
|
||||
// Show disc.track format for multi-disc albums
|
||||
let track = if has_multiple_discs {
|
||||
match (song.disc_number, song.track) {
|
||||
(Some(d), Some(t)) => format!("{}.{:02}. ", d, t),
|
||||
(None, Some(t)) => format!("{:02}. ", t),
|
||||
_ => String::new(),
|
||||
}
|
||||
} else {
|
||||
song.track
|
||||
.map(|t| format!("{:02}. ", t))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let duration = song.format_duration();
|
||||
let title = song.title.clone();
|
||||
|
||||
// Colors based on state
|
||||
let (title_color, track_color, time_color) = if is_selected {
|
||||
// When highlighted, use highlight foreground for readability
|
||||
(
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
)
|
||||
} else if is_playing {
|
||||
(colors.playing, colors.muted, colors.muted)
|
||||
} else {
|
||||
(colors.song, colors.muted, colors.muted)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(indicator.to_string(), Style::default().fg(colors.playing)),
|
||||
Span::styled(track, Style::default().fg(track_color)),
|
||||
Span::styled(title, Style::default().fg(title_color)),
|
||||
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
|
||||
]);
|
||||
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(artists.selected_song);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.artists.song_scroll_offset = list_state.offset();
|
||||
}
|
||||
7
src/ui/pages/mod.rs
Normal file
7
src/ui/pages/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! UI page implementations
|
||||
|
||||
pub mod artists;
|
||||
pub mod playlists;
|
||||
pub mod queue;
|
||||
pub mod server;
|
||||
pub mod settings;
|
||||
197
src/ui/pages/playlists.rs
Normal file
197
src/ui/pages/playlists.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Playlists page with dual-panel browser
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
|
||||
/// Render the playlists page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
// Split into two panes: [Playlists] [Songs]
|
||||
let chunks =
|
||||
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).split(area);
|
||||
|
||||
render_playlists(frame, chunks[0], state, &colors);
|
||||
render_songs(frame, chunks[1], state, &colors);
|
||||
}
|
||||
|
||||
/// Render the playlists list
|
||||
fn render_playlists(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let playlists = &state.playlists;
|
||||
|
||||
let focused = playlists.focus == 0;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Playlists ({}) ", playlists.playlists.len()))
|
||||
.border_style(border_style);
|
||||
|
||||
if playlists.playlists.is_empty() {
|
||||
let hint = Paragraph::new("No playlists found")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = playlists
|
||||
.playlists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, playlist)| {
|
||||
let is_selected = playlists.selected_playlist == Some(i);
|
||||
|
||||
let count = playlist.song_count.unwrap_or(0);
|
||||
let duration = playlist.duration.map(|d| {
|
||||
let mins = d / 60;
|
||||
let secs = d % 60;
|
||||
format!("{}:{:02}", mins, secs)
|
||||
});
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.album)
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(&playlist.name, style),
|
||||
Span::styled(
|
||||
format!(" ({} songs)", count),
|
||||
Style::default().fg(colors.muted),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(dur) = duration {
|
||||
spans.push(Span::styled(
|
||||
format!(" [{}]", dur),
|
||||
Style::default().fg(colors.muted),
|
||||
));
|
||||
}
|
||||
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▸ ");
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(playlists.selected_playlist);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.playlists.playlist_scroll_offset = list_state.offset();
|
||||
}
|
||||
|
||||
/// Render the songs in selected playlist
|
||||
fn render_songs(frame: &mut Frame, area: Rect, state: &mut AppState, colors: &ThemeColors) {
|
||||
let playlists = &state.playlists;
|
||||
|
||||
let focused = playlists.focus == 1;
|
||||
let border_style = if focused {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
let title = if !playlists.songs.is_empty() {
|
||||
format!(" Songs ({}) ", playlists.songs.len())
|
||||
} else {
|
||||
" Songs ".to_string()
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style);
|
||||
|
||||
if playlists.songs.is_empty() {
|
||||
let hint = Paragraph::new("Select a playlist to view songs")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = playlists
|
||||
.songs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, song)| {
|
||||
let is_selected = playlists.selected_song == Some(i);
|
||||
let is_playing = state
|
||||
.current_song()
|
||||
.map(|s| s.id == song.id)
|
||||
.unwrap_or(false);
|
||||
|
||||
let indicator = if is_playing { "▶ " } else { " " };
|
||||
let artist = song.artist.clone().unwrap_or_default();
|
||||
let duration = song.format_duration();
|
||||
|
||||
// Colors based on state
|
||||
let (title_color, artist_color, time_color) = if is_selected {
|
||||
(
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
colors.highlight_fg,
|
||||
)
|
||||
} else if is_playing {
|
||||
(colors.playing, colors.muted, colors.muted)
|
||||
} else {
|
||||
(colors.song, colors.muted, colors.muted)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(colors.playing)),
|
||||
Span::styled(&song.title, Style::default().fg(title_color)),
|
||||
if !artist.is_empty() {
|
||||
Span::styled(format!(" - {}", artist), Style::default().fg(artist_color))
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
Span::styled(format!(" [{}]", duration), Style::default().fg(time_color)),
|
||||
]);
|
||||
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut list = List::new(items).block(block);
|
||||
if focused {
|
||||
list = list.highlight_style(
|
||||
Style::default()
|
||||
.bg(colors.highlight_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
);
|
||||
}
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(playlists.selected_song);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.playlists.song_scroll_offset = list_state.offset();
|
||||
}
|
||||
118
src/ui/pages/queue.rs
Normal file
118
src/ui/pages/queue.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! Queue page showing current play queue
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
|
||||
|
||||
/// Render the queue page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Queue ({}) ", state.queue.len()))
|
||||
.border_style(Style::default().fg(colors.border_focused));
|
||||
|
||||
if state.queue.is_empty() {
|
||||
let hint = Paragraph::new("Queue is empty. Add songs from Artists or Playlists.")
|
||||
.style(Style::default().fg(colors.muted))
|
||||
.block(block);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.queue
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, song)| {
|
||||
let is_current = state.queue_position == Some(i);
|
||||
let is_selected = state.queue_state.selected == Some(i);
|
||||
let is_played = state.queue_position.map(|pos| i < pos).unwrap_or(false);
|
||||
|
||||
let indicator = if is_current { "▶ " } else { " " };
|
||||
|
||||
let artist = song.artist.clone().unwrap_or_default();
|
||||
let duration = song.format_duration();
|
||||
// Show disc.track for songs with disc info
|
||||
let track_info = match (song.disc_number, song.track) {
|
||||
(Some(d), Some(t)) if d > 1 => format!(" [{}.{}]", d, t),
|
||||
(_, Some(t)) => format!(" [#{}]", t),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Color scheme: played = muted, current = playing color, upcoming = song color
|
||||
let (title_style, artist_style, number_style) = if is_current {
|
||||
(
|
||||
Style::default()
|
||||
.fg(colors.playing)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(colors.playing),
|
||||
Style::default().fg(colors.playing),
|
||||
)
|
||||
} else if is_played {
|
||||
(
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.played)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.played)
|
||||
},
|
||||
Style::default().fg(colors.muted),
|
||||
Style::default().fg(colors.muted),
|
||||
)
|
||||
} else if is_selected {
|
||||
(
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(colors.muted),
|
||||
Style::default().fg(colors.muted),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(colors.song),
|
||||
Style::default().fg(colors.muted),
|
||||
Style::default().fg(colors.muted),
|
||||
)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{:3}. ", i + 1), number_style),
|
||||
Span::styled(indicator, Style::default().fg(colors.playing)),
|
||||
Span::styled(song.title.clone(), title_style),
|
||||
Span::styled(track_info, Style::default().fg(colors.muted)),
|
||||
if !artist.is_empty() {
|
||||
Span::styled(format!(" - {}", artist), artist_style)
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
Span::styled(
|
||||
format!(" [{}]", duration),
|
||||
Style::default().fg(colors.muted),
|
||||
),
|
||||
]);
|
||||
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(block)
|
||||
.highlight_style(Style::default().bg(colors.highlight_bg))
|
||||
.highlight_symbol("▸ ");
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(state.queue_state.selected);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
state.queue_state.scroll_offset = list_state.offset();
|
||||
}
|
||||
179
src/ui/pages/server.rs
Normal file
179
src/ui/pages/server.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Server page with connection settings form
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
/// Render the server page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Server Connection ")
|
||||
.border_style(Style::default().fg(colors.border_focused));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.height < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let server = &state.server_state;
|
||||
|
||||
// Layout fields vertically with spacing
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(4), // Server URL (1 label + 3 field)
|
||||
Constraint::Length(4), // Username (1 label + 3 field)
|
||||
Constraint::Length(4), // Password (1 label + 3 field)
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(1), // Test button
|
||||
Constraint::Length(1), // Save button
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Min(1), // Status
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Server URL field - show cursor when selected (always editable)
|
||||
render_field(
|
||||
frame,
|
||||
chunks[1],
|
||||
"Server URL",
|
||||
&server.base_url,
|
||||
server.selected_field == 0,
|
||||
server.selected_field == 0, // cursor when selected
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Username field
|
||||
render_field(
|
||||
frame,
|
||||
chunks[2],
|
||||
"Username",
|
||||
&server.username,
|
||||
server.selected_field == 1,
|
||||
server.selected_field == 1,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Password field
|
||||
render_field(
|
||||
frame,
|
||||
chunks[3],
|
||||
"Password",
|
||||
&"*".repeat(server.password.len()),
|
||||
server.selected_field == 2,
|
||||
server.selected_field == 2,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Test button
|
||||
render_button(
|
||||
frame,
|
||||
chunks[5],
|
||||
"Test Connection",
|
||||
server.selected_field == 3,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Save button
|
||||
render_button(
|
||||
frame,
|
||||
chunks[6],
|
||||
"Save",
|
||||
server.selected_field == 4,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Status message
|
||||
if let Some(ref status) = server.status {
|
||||
let style: Style = if status.contains("failed") || status.contains("error") {
|
||||
Style::default().fg(colors.error)
|
||||
} else if status.contains("saved") || status.contains("success") {
|
||||
Style::default().fg(colors.success)
|
||||
} else {
|
||||
Style::default().fg(colors.accent)
|
||||
};
|
||||
|
||||
let status_text = Paragraph::new(status.as_str()).style(style);
|
||||
frame.render_widget(status_text, chunks[8]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a form field
|
||||
fn render_field(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
label: &str,
|
||||
value: &str,
|
||||
selected: bool,
|
||||
editing: bool,
|
||||
colors: &ThemeColors,
|
||||
) {
|
||||
let label_style = if selected {
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.highlight_fg)
|
||||
};
|
||||
|
||||
let value_style = if editing {
|
||||
Style::default().fg(colors.accent)
|
||||
} else if selected {
|
||||
Style::default().fg(colors.primary)
|
||||
} else {
|
||||
Style::default().fg(colors.muted)
|
||||
};
|
||||
|
||||
let border_style = if selected {
|
||||
Style::default().fg(colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(colors.border_unfocused)
|
||||
};
|
||||
|
||||
// Label on first line
|
||||
let label_text = Paragraph::new(label).style(label_style);
|
||||
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
|
||||
|
||||
// Value field with border on second line (height 3 = 1 top border + 1 content + 1 bottom border)
|
||||
let field_area = Rect::new(area.x, area.y + 1, area.width.min(60), 3);
|
||||
|
||||
let display_value = if editing {
|
||||
format!("{}▏", value)
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
|
||||
let field = Paragraph::new(display_value).style(value_style).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style),
|
||||
);
|
||||
|
||||
frame.render_widget(field, field_area);
|
||||
}
|
||||
|
||||
/// Render a button
|
||||
fn render_button(frame: &mut Frame, area: Rect, label: &str, selected: bool, colors: &ThemeColors) {
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.fg(colors.highlight_fg)
|
||||
.bg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.muted)
|
||||
};
|
||||
|
||||
let text = format!("[ {} ]", label);
|
||||
let button = Paragraph::new(text).style(style);
|
||||
frame.render_widget(button, area);
|
||||
}
|
||||
123
src/ui/pages/settings.rs
Normal file
123
src/ui/pages/settings.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Settings page with app preferences and theming
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::state::AppState;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
/// Render the settings page
|
||||
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let colors = *state.settings_state.theme_colors();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Settings ")
|
||||
.border_style(Style::default().fg(colors.border_focused));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if inner.height < 8 {
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = &state.settings_state;
|
||||
|
||||
// Layout fields vertically with spacing
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(2), // Theme selector
|
||||
Constraint::Length(1), // Spacing
|
||||
Constraint::Length(2), // Cava toggle
|
||||
Constraint::Min(1), // Remaining space
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Theme selector (field 0)
|
||||
render_option(
|
||||
frame,
|
||||
chunks[1],
|
||||
"Theme",
|
||||
settings.theme_name(),
|
||||
settings.selected_field == 0,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Cava toggle (field 1)
|
||||
let cava_value = if !state.cava_available {
|
||||
"Off (cava not found)"
|
||||
} else if settings.cava_enabled {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
};
|
||||
|
||||
render_option(
|
||||
frame,
|
||||
chunks[3],
|
||||
"Cava Visualizer",
|
||||
cava_value,
|
||||
settings.selected_field == 1,
|
||||
&colors,
|
||||
);
|
||||
|
||||
// Help text at bottom
|
||||
let help_text = match settings.selected_field {
|
||||
0 => "← → or Enter to change theme (auto-saves)",
|
||||
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
|
||||
1 => "cava is not installed on this system",
|
||||
_ => "",
|
||||
};
|
||||
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));
|
||||
|
||||
let help_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y + inner.height.saturating_sub(2),
|
||||
inner.width,
|
||||
1,
|
||||
);
|
||||
frame.render_widget(help, help_area);
|
||||
}
|
||||
|
||||
/// Render an option selector
|
||||
fn render_option(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
label: &str,
|
||||
value: &str,
|
||||
selected: bool,
|
||||
colors: &ThemeColors,
|
||||
) {
|
||||
let label_style = if selected {
|
||||
Style::default()
|
||||
.fg(colors.primary)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.highlight_fg)
|
||||
};
|
||||
|
||||
let value_style = if selected {
|
||||
Style::default().fg(colors.accent)
|
||||
} else {
|
||||
Style::default().fg(colors.muted)
|
||||
};
|
||||
|
||||
// Label
|
||||
let label_text = Paragraph::new(label).style(label_style);
|
||||
frame.render_widget(label_text, Rect::new(area.x, area.y, area.width, 1));
|
||||
|
||||
// Value with arrows
|
||||
let value_text = if selected {
|
||||
format!(" ◀ {} ▶", value)
|
||||
} else {
|
||||
format!(" {}", value)
|
||||
};
|
||||
|
||||
let value_para = Paragraph::new(value_text).style(value_style);
|
||||
frame.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 1));
|
||||
}
|
||||
553
src/ui/theme.rs
Normal file
553
src/ui/theme.rs
Normal file
@@ -0,0 +1,553 @@
|
||||
//! Theme color definitions — file-based themes loaded from ~/.config/ferrosonic/themes/
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::paths;
|
||||
|
||||
/// Color palette for a theme
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ThemeColors {
|
||||
/// Primary highlight color (focused elements, selected tabs)
|
||||
pub primary: Color,
|
||||
/// Secondary color (borders, less important elements)
|
||||
pub secondary: Color,
|
||||
/// Accent color (currently playing, important highlights)
|
||||
pub accent: Color,
|
||||
/// Artist names
|
||||
pub artist: Color,
|
||||
/// Album names
|
||||
pub album: Color,
|
||||
/// Song titles (default)
|
||||
pub song: Color,
|
||||
/// Muted text (track numbers, durations, hints)
|
||||
pub muted: Color,
|
||||
/// Selection/highlight background
|
||||
pub highlight_bg: Color,
|
||||
/// Text on highlighted background
|
||||
pub highlight_fg: Color,
|
||||
/// Success messages
|
||||
pub success: Color,
|
||||
/// Error messages
|
||||
pub error: Color,
|
||||
/// Playing indicator
|
||||
pub playing: Color,
|
||||
/// Played songs in queue
|
||||
pub played: Color,
|
||||
/// Border color (focused)
|
||||
pub border_focused: Color,
|
||||
/// Border color (unfocused)
|
||||
pub border_unfocused: Color,
|
||||
}
|
||||
|
||||
/// A loaded theme: display name + colors + cava gradients
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThemeData {
|
||||
/// Display name (e.g. "Catppuccin", "Default")
|
||||
pub name: String,
|
||||
/// UI colors
|
||||
pub colors: ThemeColors,
|
||||
/// Cava vertical gradient (8 hex strings)
|
||||
pub cava_gradient: [String; 8],
|
||||
/// Cava horizontal gradient (8 hex strings)
|
||||
pub cava_horizontal_gradient: [String; 8],
|
||||
}
|
||||
|
||||
// ── TOML deserialization structs ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ThemeFile {
|
||||
colors: ThemeFileColors,
|
||||
cava: Option<ThemeFileCava>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ThemeFileColors {
|
||||
primary: String,
|
||||
secondary: String,
|
||||
accent: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
song: String,
|
||||
muted: String,
|
||||
highlight_bg: String,
|
||||
highlight_fg: String,
|
||||
success: String,
|
||||
error: String,
|
||||
playing: String,
|
||||
played: String,
|
||||
border_focused: String,
|
||||
border_unfocused: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ThemeFileCava {
|
||||
gradient: Option<Vec<String>>,
|
||||
horizontal_gradient: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ── Hex color parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
fn hex_to_color(hex: &str) -> Color {
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() == 6 {
|
||||
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||
u8::from_str_radix(&hex[0..2], 16),
|
||||
u8::from_str_radix(&hex[2..4], 16),
|
||||
u8::from_str_radix(&hex[4..6], 16),
|
||||
) {
|
||||
return Color::Rgb(r, g, b);
|
||||
}
|
||||
}
|
||||
warn!("Invalid hex color '{}', falling back to white", hex);
|
||||
Color::White
|
||||
}
|
||||
|
||||
fn parse_gradient(values: &[String], fallback: &[&str; 8]) -> [String; 8] {
|
||||
let mut result: [String; 8] = std::array::from_fn(|i| fallback[i].to_string());
|
||||
for (i, v) in values.iter().enumerate().take(8) {
|
||||
result[i] = v.clone();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── ThemeData construction ────────────────────────────────────────────────────
|
||||
|
||||
impl ThemeData {
|
||||
fn from_file_content(name: &str, content: &str) -> Result<Self, String> {
|
||||
let file: ThemeFile =
|
||||
toml::from_str(content).map_err(|e| format!("Failed to parse theme '{}': {}", name, e))?;
|
||||
|
||||
let c = &file.colors;
|
||||
let colors = ThemeColors {
|
||||
primary: hex_to_color(&c.primary),
|
||||
secondary: hex_to_color(&c.secondary),
|
||||
accent: hex_to_color(&c.accent),
|
||||
artist: hex_to_color(&c.artist),
|
||||
album: hex_to_color(&c.album),
|
||||
song: hex_to_color(&c.song),
|
||||
muted: hex_to_color(&c.muted),
|
||||
highlight_bg: hex_to_color(&c.highlight_bg),
|
||||
highlight_fg: hex_to_color(&c.highlight_fg),
|
||||
success: hex_to_color(&c.success),
|
||||
error: hex_to_color(&c.error),
|
||||
playing: hex_to_color(&c.playing),
|
||||
played: hex_to_color(&c.played),
|
||||
border_focused: hex_to_color(&c.border_focused),
|
||||
border_unfocused: hex_to_color(&c.border_unfocused),
|
||||
};
|
||||
|
||||
let default_g: [&str; 8] = [
|
||||
"#59cc33", "#cccc33", "#cc8033", "#cc5533",
|
||||
"#cc3333", "#bb1111", "#990000", "#990000",
|
||||
];
|
||||
let default_h: [&str; 8] = [
|
||||
"#c45161", "#e094a0", "#f2b6c0", "#f2dde1",
|
||||
"#cbc7d8", "#8db7d2", "#5e62a9", "#434279",
|
||||
];
|
||||
|
||||
let cava = file.cava.as_ref();
|
||||
let cava_gradient = match cava.and_then(|c| c.gradient.as_ref()) {
|
||||
Some(g) => parse_gradient(g, &default_g),
|
||||
None => std::array::from_fn(|i| default_g[i].to_string()),
|
||||
};
|
||||
let cava_horizontal_gradient = match cava.and_then(|c| c.horizontal_gradient.as_ref()) {
|
||||
Some(h) => parse_gradient(h, &default_h),
|
||||
None => std::array::from_fn(|i| default_h[i].to_string()),
|
||||
};
|
||||
|
||||
Ok(ThemeData {
|
||||
name: name.to_string(),
|
||||
colors,
|
||||
cava_gradient,
|
||||
cava_horizontal_gradient,
|
||||
})
|
||||
}
|
||||
|
||||
/// The hardcoded Default theme
|
||||
pub fn default_theme() -> Self {
|
||||
ThemeData {
|
||||
name: "Default".to_string(),
|
||||
colors: ThemeColors {
|
||||
primary: Color::Cyan,
|
||||
secondary: Color::DarkGray,
|
||||
accent: Color::Yellow,
|
||||
artist: Color::LightGreen,
|
||||
album: Color::Magenta,
|
||||
song: Color::Magenta,
|
||||
muted: Color::Gray,
|
||||
highlight_bg: Color::Rgb(102, 51, 153),
|
||||
highlight_fg: Color::White,
|
||||
success: Color::Green,
|
||||
error: Color::Red,
|
||||
playing: Color::LightGreen,
|
||||
played: Color::Red,
|
||||
border_focused: Color::Cyan,
|
||||
border_unfocused: Color::DarkGray,
|
||||
},
|
||||
cava_gradient: [
|
||||
"#59cc33".into(), "#cccc33".into(), "#cc8033".into(), "#cc5533".into(),
|
||||
"#cc3333".into(), "#bb1111".into(), "#990000".into(), "#990000".into(),
|
||||
],
|
||||
cava_horizontal_gradient: [
|
||||
"#c45161".into(), "#e094a0".into(), "#f2b6c0".into(), "#f2dde1".into(),
|
||||
"#cbc7d8".into(), "#8db7d2".into(), "#5e62a9".into(), "#434279".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Load all themes: Default (hardcoded) + TOML files from themes dir (sorted alphabetically)
|
||||
pub fn load_themes() -> Vec<ThemeData> {
|
||||
let mut themes = vec![ThemeData::default_theme()];
|
||||
|
||||
if let Some(dir) = paths::themes_dir() {
|
||||
if dir.is_dir() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map_or(false, |ext| ext == "toml")
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
// Capitalize first letter for display name
|
||||
let name = titlecase_filename(stem);
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => match ThemeData::from_file_content(&name, &content) {
|
||||
Ok(theme) => {
|
||||
info!("Loaded theme '{}' from {}", name, path.display());
|
||||
themes.push(theme);
|
||||
}
|
||||
Err(e) => error!("{}", e),
|
||||
},
|
||||
Err(e) => error!("Failed to read {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
themes
|
||||
}
|
||||
|
||||
/// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine"
|
||||
fn titlecase_filename(s: &str) -> String {
|
||||
s.split(|c: char| c == '-' || c == '_')
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => {
|
||||
let upper: String = first.to_uppercase().collect();
|
||||
upper + chars.as_str()
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ── Seeding built-in themes ───────────────────────────────────────────────────
|
||||
|
||||
/// Write the built-in themes as TOML files into the given directory.
|
||||
/// Only writes files that don't already exist.
|
||||
pub fn seed_default_themes(dir: &Path) {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
error!("Failed to create themes directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
for (filename, content) in BUILTIN_THEMES {
|
||||
let path = dir.join(filename);
|
||||
if !path.exists() {
|
||||
if let Err(e) = std::fs::write(&path, content) {
|
||||
error!("Failed to write theme {}: {}", filename, e);
|
||||
} else {
|
||||
info!("Seeded theme file: {}", filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BUILTIN_THEMES: &[(&str, &str)] = &[
|
||||
("monokai.toml", r##"[colors]
|
||||
primary = "#a6e22e"
|
||||
secondary = "#75715e"
|
||||
accent = "#fd971f"
|
||||
artist = "#a6e22e"
|
||||
album = "#f92672"
|
||||
song = "#e6db74"
|
||||
muted = "#75715e"
|
||||
highlight_bg = "#49483e"
|
||||
highlight_fg = "#f8f8f2"
|
||||
success = "#a6e22e"
|
||||
error = "#f92672"
|
||||
playing = "#fd971f"
|
||||
played = "#75715e"
|
||||
border_focused = "#a6e22e"
|
||||
border_unfocused = "#49483e"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
|
||||
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
|
||||
"##),
|
||||
|
||||
("dracula.toml", r##"[colors]
|
||||
primary = "#bd93f9"
|
||||
secondary = "#6272a4"
|
||||
accent = "#ffb86c"
|
||||
artist = "#50fa7b"
|
||||
album = "#ff79c6"
|
||||
song = "#8be9fd"
|
||||
muted = "#6272a4"
|
||||
highlight_bg = "#44475a"
|
||||
highlight_fg = "#f8f8f2"
|
||||
success = "#50fa7b"
|
||||
error = "#ff5555"
|
||||
playing = "#ffb86c"
|
||||
played = "#6272a4"
|
||||
border_focused = "#bd93f9"
|
||||
border_unfocused = "#44475a"
|
||||
|
||||
[cava]
|
||||
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
|
||||
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
|
||||
"##),
|
||||
|
||||
("nord.toml", r##"[colors]
|
||||
primary = "#88c0d0"
|
||||
secondary = "#4c566a"
|
||||
accent = "#ebcb8b"
|
||||
artist = "#a3be8c"
|
||||
album = "#b48ead"
|
||||
song = "#88c0d0"
|
||||
muted = "#4c566a"
|
||||
highlight_bg = "#434c5e"
|
||||
highlight_fg = "#eceff4"
|
||||
success = "#a3be8c"
|
||||
error = "#bf616a"
|
||||
playing = "#ebcb8b"
|
||||
played = "#4c566a"
|
||||
border_focused = "#88c0d0"
|
||||
border_unfocused = "#3b4252"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
|
||||
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
|
||||
"##),
|
||||
|
||||
("gruvbox.toml", r##"[colors]
|
||||
primary = "#d79921"
|
||||
secondary = "#928374"
|
||||
accent = "#fe8019"
|
||||
artist = "#b8bb26"
|
||||
album = "#d3869b"
|
||||
song = "#83a598"
|
||||
muted = "#928374"
|
||||
highlight_bg = "#504945"
|
||||
highlight_fg = "#ebdbb2"
|
||||
success = "#b8bb26"
|
||||
error = "#fb4934"
|
||||
playing = "#fe8019"
|
||||
played = "#928374"
|
||||
border_focused = "#d79921"
|
||||
border_unfocused = "#3c3836"
|
||||
|
||||
[cava]
|
||||
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
|
||||
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
|
||||
"##),
|
||||
|
||||
("catppuccin.toml", r##"[colors]
|
||||
primary = "#89b4fa"
|
||||
secondary = "#585b70"
|
||||
accent = "#f9e2af"
|
||||
artist = "#a6e3a1"
|
||||
album = "#f5c2e7"
|
||||
song = "#94e2d5"
|
||||
muted = "#6c7086"
|
||||
highlight_bg = "#45475a"
|
||||
highlight_fg = "#cdd6f4"
|
||||
success = "#a6e3a1"
|
||||
error = "#f38ba8"
|
||||
playing = "#f9e2af"
|
||||
played = "#6c7086"
|
||||
border_focused = "#89b4fa"
|
||||
border_unfocused = "#45475a"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
|
||||
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
|
||||
"##),
|
||||
|
||||
("solarized.toml", r##"[colors]
|
||||
primary = "#268bd2"
|
||||
secondary = "#586e75"
|
||||
accent = "#b58900"
|
||||
artist = "#859900"
|
||||
album = "#d33682"
|
||||
song = "#2aa198"
|
||||
muted = "#586e75"
|
||||
highlight_bg = "#073642"
|
||||
highlight_fg = "#eee8d5"
|
||||
success = "#859900"
|
||||
error = "#dc322f"
|
||||
playing = "#b58900"
|
||||
played = "#586e75"
|
||||
border_focused = "#268bd2"
|
||||
border_unfocused = "#073642"
|
||||
|
||||
[cava]
|
||||
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
|
||||
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
|
||||
"##),
|
||||
|
||||
("tokyo-night.toml", r##"[colors]
|
||||
primary = "#7aa2f7"
|
||||
secondary = "#3d59a1"
|
||||
accent = "#e0af68"
|
||||
artist = "#9ece6a"
|
||||
album = "#bb9af7"
|
||||
song = "#7dcfff"
|
||||
muted = "#565f89"
|
||||
highlight_bg = "#292e42"
|
||||
highlight_fg = "#c0caf5"
|
||||
success = "#9ece6a"
|
||||
error = "#f7768e"
|
||||
playing = "#e0af68"
|
||||
played = "#565f89"
|
||||
border_focused = "#7aa2f7"
|
||||
border_unfocused = "#292e42"
|
||||
|
||||
[cava]
|
||||
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
|
||||
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
|
||||
"##),
|
||||
|
||||
("rose-pine.toml", r##"[colors]
|
||||
primary = "#c4a7e7"
|
||||
secondary = "#6e6a86"
|
||||
accent = "#f6c177"
|
||||
artist = "#9ccfd8"
|
||||
album = "#ebbcba"
|
||||
song = "#31748f"
|
||||
muted = "#6e6a86"
|
||||
highlight_bg = "#393552"
|
||||
highlight_fg = "#e0def4"
|
||||
success = "#9ccfd8"
|
||||
error = "#eb6f92"
|
||||
playing = "#f6c177"
|
||||
played = "#6e6a86"
|
||||
border_focused = "#c4a7e7"
|
||||
border_unfocused = "#393552"
|
||||
|
||||
[cava]
|
||||
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
|
||||
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
|
||||
"##),
|
||||
|
||||
("everforest.toml", r##"[colors]
|
||||
primary = "#a7c080"
|
||||
secondary = "#859289"
|
||||
accent = "#dbbc7f"
|
||||
artist = "#83c092"
|
||||
album = "#d699b6"
|
||||
song = "#7fbbb3"
|
||||
muted = "#859289"
|
||||
highlight_bg = "#505851"
|
||||
highlight_fg = "#d3c6aa"
|
||||
success = "#a7c080"
|
||||
error = "#e67e80"
|
||||
playing = "#dbbc7f"
|
||||
played = "#859289"
|
||||
border_focused = "#a7c080"
|
||||
border_unfocused = "#505851"
|
||||
|
||||
[cava]
|
||||
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
|
||||
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
|
||||
"##),
|
||||
|
||||
("kanagawa.toml", r##"[colors]
|
||||
primary = "#7e9cd8"
|
||||
secondary = "#54546d"
|
||||
accent = "#e6c384"
|
||||
artist = "#98bb6c"
|
||||
album = "#957fb8"
|
||||
song = "#7fb4ca"
|
||||
muted = "#727169"
|
||||
highlight_bg = "#363646"
|
||||
highlight_fg = "#dcd7ba"
|
||||
success = "#98bb6c"
|
||||
error = "#ff5d62"
|
||||
playing = "#e6c384"
|
||||
played = "#727169"
|
||||
border_focused = "#7e9cd8"
|
||||
border_unfocused = "#363646"
|
||||
|
||||
[cava]
|
||||
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
|
||||
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
|
||||
"##),
|
||||
|
||||
("one-dark.toml", r##"[colors]
|
||||
primary = "#61afef"
|
||||
secondary = "#5c6370"
|
||||
accent = "#e5c07b"
|
||||
artist = "#98c379"
|
||||
album = "#c678dd"
|
||||
song = "#56b6c2"
|
||||
muted = "#5c6370"
|
||||
highlight_bg = "#3e4451"
|
||||
highlight_fg = "#abb2bf"
|
||||
success = "#98c379"
|
||||
error = "#e06c75"
|
||||
playing = "#e5c07b"
|
||||
played = "#5c6370"
|
||||
border_focused = "#61afef"
|
||||
border_unfocused = "#3e4451"
|
||||
|
||||
[cava]
|
||||
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
|
||||
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
|
||||
"##),
|
||||
|
||||
("ayu-dark.toml", r##"[colors]
|
||||
primary = "#59c2ff"
|
||||
secondary = "#6b788a"
|
||||
accent = "#e6b450"
|
||||
artist = "#aad94c"
|
||||
album = "#d2a6ff"
|
||||
song = "#95e6cb"
|
||||
muted = "#6b788a"
|
||||
highlight_bg = "#2f3846"
|
||||
highlight_fg = "#bfc7d5"
|
||||
success = "#aad94c"
|
||||
error = "#f07178"
|
||||
playing = "#e6b450"
|
||||
played = "#6b788a"
|
||||
border_focused = "#59c2ff"
|
||||
border_unfocused = "#2f3846"
|
||||
|
||||
[cava]
|
||||
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
|
||||
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
|
||||
"##),
|
||||
];
|
||||
61
src/ui/widgets/cava.rs
Normal file
61
src/ui/widgets/cava.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Cava audio visualizer widget — renders captured noncurses output
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::Widget,
|
||||
};
|
||||
|
||||
use crate::app::state::{CavaColor, CavaRow};
|
||||
|
||||
pub struct CavaWidget<'a> {
|
||||
screen: &'a [CavaRow],
|
||||
}
|
||||
|
||||
impl<'a> CavaWidget<'a> {
|
||||
pub fn new(screen: &'a [CavaRow]) -> Self {
|
||||
Self { screen }
|
||||
}
|
||||
}
|
||||
|
||||
fn cava_color_to_ratatui(c: CavaColor) -> Option<Color> {
|
||||
match c {
|
||||
CavaColor::Default => None,
|
||||
CavaColor::Indexed(i) => Some(Color::Indexed(i)),
|
||||
CavaColor::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for CavaWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width == 0 || area.height == 0 || self.screen.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (row_idx, cava_row) in self.screen.iter().enumerate() {
|
||||
if row_idx >= area.height as usize {
|
||||
break;
|
||||
}
|
||||
let y = area.y + row_idx as u16;
|
||||
let mut x = area.x;
|
||||
|
||||
for span in &cava_row.spans {
|
||||
for ch in span.text.chars() {
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if let Some(fg) = cava_color_to_ratatui(span.fg) {
|
||||
style = style.fg(fg);
|
||||
}
|
||||
if let Some(bg) = cava_color_to_ratatui(span.bg) {
|
||||
style = style.bg(bg);
|
||||
}
|
||||
buf[(x, y)].set_char(ch).set_style(style);
|
||||
x += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ui/widgets/mod.rs
Normal file
8
src/ui/widgets/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Custom UI widgets
|
||||
|
||||
pub mod cava;
|
||||
pub mod now_playing;
|
||||
pub mod progress_bar;
|
||||
|
||||
pub use cava::CavaWidget;
|
||||
pub use now_playing::NowPlayingWidget;
|
||||
283
src/ui/widgets/now_playing.rs
Normal file
283
src/ui/widgets/now_playing.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Now playing display widget
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Alignment, Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::app::state::NowPlaying;
|
||||
use crate::ui::theme::ThemeColors;
|
||||
|
||||
/// Now playing panel widget
|
||||
pub struct NowPlayingWidget<'a> {
|
||||
now_playing: &'a NowPlaying,
|
||||
focused: bool,
|
||||
colors: ThemeColors,
|
||||
}
|
||||
|
||||
impl<'a> NowPlayingWidget<'a> {
|
||||
pub fn new(now_playing: &'a NowPlaying, colors: ThemeColors) -> Self {
|
||||
Self {
|
||||
now_playing,
|
||||
focused: false,
|
||||
colors,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn focused(mut self, focused: bool) -> Self {
|
||||
self.focused = focused;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for NowPlayingWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
// Need at least 6 rows for full display
|
||||
if area.height < 4 || area.width < 20 {
|
||||
return;
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Now Playing ")
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(self.colors.border_focused)
|
||||
} else {
|
||||
Style::default().fg(self.colors.border_unfocused)
|
||||
});
|
||||
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if something is playing
|
||||
if self.now_playing.song.is_none() {
|
||||
let no_track = Paragraph::new("No track playing")
|
||||
.style(Style::default().fg(self.colors.muted))
|
||||
.alignment(Alignment::Center);
|
||||
no_track.render(inner, buf);
|
||||
return;
|
||||
}
|
||||
|
||||
let song = self.now_playing.song.as_ref().unwrap();
|
||||
|
||||
// Build centered lines like Go version:
|
||||
// Line 1: Artist (green)
|
||||
// Line 2: Album (purple/magenta)
|
||||
// Line 3: Title (white, bold)
|
||||
// Line 4: Quality info (gray)
|
||||
// Line 5: Progress bar
|
||||
|
||||
let artist = song.artist.clone().unwrap_or_default();
|
||||
let album = song.album.clone().unwrap_or_default();
|
||||
let title = song.title.clone();
|
||||
|
||||
// Build quality string
|
||||
let mut quality_parts = Vec::new();
|
||||
if let Some(ref fmt) = self.now_playing.format {
|
||||
quality_parts.push(fmt.to_string().to_uppercase());
|
||||
}
|
||||
if let Some(bits) = self.now_playing.bit_depth {
|
||||
quality_parts.push(format!("{}-bit", bits));
|
||||
}
|
||||
if let Some(rate) = self.now_playing.sample_rate {
|
||||
let khz = rate as f64 / 1000.0;
|
||||
if khz == khz.floor() {
|
||||
quality_parts.push(format!("{}kHz", khz as u32));
|
||||
} else {
|
||||
quality_parts.push(format!("{:.1}kHz", khz));
|
||||
}
|
||||
}
|
||||
if let Some(ref channels) = self.now_playing.channels {
|
||||
quality_parts.push(channels.to_string());
|
||||
}
|
||||
let quality = quality_parts.join(" │ ");
|
||||
|
||||
// Layout based on available height
|
||||
if inner.height >= 5 {
|
||||
// Full layout with separate lines
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Artist
|
||||
Constraint::Length(1), // Album
|
||||
Constraint::Length(1), // Title
|
||||
Constraint::Length(1), // Quality
|
||||
Constraint::Length(1), // Progress
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Artist line (centered, artist color)
|
||||
let artist_line = Line::from(vec![Span::styled(
|
||||
&artist,
|
||||
Style::default().fg(self.colors.artist),
|
||||
)]);
|
||||
Paragraph::new(artist_line)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[0], buf);
|
||||
|
||||
// Album line (centered, album color)
|
||||
let album_line = Line::from(vec![Span::styled(
|
||||
&album,
|
||||
Style::default().fg(self.colors.album),
|
||||
)]);
|
||||
Paragraph::new(album_line)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[1], buf);
|
||||
|
||||
// Title line (centered, bold)
|
||||
let title_line = Line::from(vec![Span::styled(
|
||||
&title,
|
||||
Style::default()
|
||||
.fg(self.colors.highlight_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]);
|
||||
Paragraph::new(title_line)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[2], buf);
|
||||
|
||||
// Quality line (centered, muted)
|
||||
if !quality.is_empty() {
|
||||
let quality_line = Line::from(vec![Span::styled(
|
||||
&quality,
|
||||
Style::default().fg(self.colors.muted),
|
||||
)]);
|
||||
Paragraph::new(quality_line)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[3], buf);
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
render_progress_bar(
|
||||
chunks[4],
|
||||
buf,
|
||||
self.now_playing.progress_percent(),
|
||||
&self.now_playing.format_position(),
|
||||
&self.now_playing.format_duration(),
|
||||
&self.colors,
|
||||
);
|
||||
} else if inner.height >= 3 {
|
||||
// Compact layout
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Artist - Title
|
||||
Constraint::Length(1), // Album / Quality
|
||||
Constraint::Length(1), // Progress
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Combined artist - title line
|
||||
let line1 = Line::from(vec![
|
||||
Span::styled(
|
||||
&title,
|
||||
Style::default()
|
||||
.fg(self.colors.highlight_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" - ", Style::default().fg(self.colors.muted)),
|
||||
Span::styled(&artist, Style::default().fg(self.colors.artist)),
|
||||
]);
|
||||
Paragraph::new(line1)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[0], buf);
|
||||
|
||||
// Album line
|
||||
let line2 = Line::from(vec![Span::styled(
|
||||
&album,
|
||||
Style::default().fg(self.colors.album),
|
||||
)]);
|
||||
Paragraph::new(line2)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[1], buf);
|
||||
|
||||
// Progress bar
|
||||
render_progress_bar(
|
||||
chunks[2],
|
||||
buf,
|
||||
self.now_playing.progress_percent(),
|
||||
&self.now_playing.format_position(),
|
||||
&self.now_playing.format_duration(),
|
||||
&self.colors,
|
||||
);
|
||||
} else {
|
||||
// Minimal layout
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // Title
|
||||
Constraint::Length(1), // Progress
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let line1 = Line::from(vec![Span::styled(
|
||||
&title,
|
||||
Style::default().fg(self.colors.highlight_fg),
|
||||
)]);
|
||||
Paragraph::new(line1)
|
||||
.alignment(Alignment::Center)
|
||||
.render(chunks[0], buf);
|
||||
|
||||
render_progress_bar(
|
||||
chunks[1],
|
||||
buf,
|
||||
self.now_playing.progress_percent(),
|
||||
&self.now_playing.format_position(),
|
||||
&self.now_playing.format_duration(),
|
||||
&self.colors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a simple progress bar
|
||||
fn render_progress_bar(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
progress: f64,
|
||||
pos: &str,
|
||||
dur: &str,
|
||||
colors: &ThemeColors,
|
||||
) {
|
||||
if area.width < 15 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format: "00:00 / 00:00 [════════════────────]"
|
||||
let time_str = format!("{} / {}", pos, dur);
|
||||
let time_width = time_str.len() as u16;
|
||||
|
||||
// Calculate positions - center the whole thing
|
||||
let bar_width = area.width.saturating_sub(time_width + 3); // 2 spaces + some padding
|
||||
let total_width = time_width + 2 + bar_width;
|
||||
let start_x = area.x + (area.width.saturating_sub(total_width)) / 2;
|
||||
|
||||
// Draw time string
|
||||
buf.set_string(
|
||||
start_x,
|
||||
area.y,
|
||||
&time_str,
|
||||
Style::default().fg(colors.highlight_fg),
|
||||
);
|
||||
|
||||
// Draw progress bar
|
||||
let bar_start = start_x + time_width + 2;
|
||||
if bar_width > 0 {
|
||||
let filled = (bar_width as f64 * progress) as u16;
|
||||
|
||||
// Draw filled portion (success color like Go version)
|
||||
for x in bar_start..(bar_start + filled) {
|
||||
buf[(x, area.y)]
|
||||
.set_char('━')
|
||||
.set_style(Style::default().fg(colors.success));
|
||||
}
|
||||
|
||||
// Draw empty portion
|
||||
for x in (bar_start + filled)..(bar_start + bar_width) {
|
||||
buf[(x, area.y)]
|
||||
.set_char('─')
|
||||
.set_style(Style::default().fg(colors.muted));
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/ui/widgets/progress_bar.rs
Normal file
165
src/ui/widgets/progress_bar.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Progress bar widget with seek support
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::Widget,
|
||||
};
|
||||
|
||||
/// A horizontal progress bar with time display
|
||||
#[allow(dead_code)]
|
||||
pub struct ProgressBar<'a> {
|
||||
/// Progress value (0.0 to 1.0)
|
||||
progress: f64,
|
||||
/// Current position formatted
|
||||
position_text: &'a str,
|
||||
/// Total duration formatted
|
||||
duration_text: &'a str,
|
||||
/// Filled portion style
|
||||
filled_style: Style,
|
||||
/// Empty portion style
|
||||
empty_style: Style,
|
||||
/// Text style
|
||||
text_style: Style,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<'a> ProgressBar<'a> {
|
||||
pub fn new(progress: f64, position_text: &'a str, duration_text: &'a str) -> Self {
|
||||
Self {
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
position_text,
|
||||
duration_text,
|
||||
filled_style: Style::default().bg(Color::Blue),
|
||||
empty_style: Style::default().bg(Color::DarkGray),
|
||||
text_style: Style::default().fg(Color::White),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filled_style(mut self, style: Style) -> Self {
|
||||
self.filled_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn empty_style(mut self, style: Style) -> Self {
|
||||
self.empty_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text_style(mut self, style: Style) -> Self {
|
||||
self.text_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate position from x coordinate within the bar area
|
||||
pub fn position_from_x(area: Rect, x: u16) -> Option<f64> {
|
||||
// Account for time text margins
|
||||
let bar_start = area.x + 8; // "00:00 " prefix
|
||||
let bar_end = area.x + area.width - 8; // " 00:00" suffix
|
||||
|
||||
if x >= bar_start && x < bar_end {
|
||||
let bar_width = bar_end - bar_start;
|
||||
let relative_x = x - bar_start;
|
||||
Some(relative_x as f64 / bar_width as f64)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ProgressBar<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.width < 20 || area.height < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format: "00:00 [==========----------] 00:00"
|
||||
let pos_width = self.position_text.len();
|
||||
let dur_width = self.duration_text.len();
|
||||
|
||||
// Draw position text
|
||||
buf.set_string(area.x, area.y, self.position_text, self.text_style);
|
||||
|
||||
// Draw duration text
|
||||
let dur_x = area.x + area.width - dur_width as u16;
|
||||
buf.set_string(dur_x, area.y, self.duration_text, self.text_style);
|
||||
|
||||
// Calculate bar area
|
||||
let bar_x = area.x + pos_width as u16 + 1;
|
||||
let bar_width = area
|
||||
.width
|
||||
.saturating_sub((pos_width + dur_width + 2) as u16);
|
||||
|
||||
if bar_width > 0 {
|
||||
let filled_width = (bar_width as f64 * self.progress) as u16;
|
||||
|
||||
// Draw filled portion
|
||||
for x in bar_x..(bar_x + filled_width) {
|
||||
buf[(x, area.y)].set_char('━').set_style(self.filled_style);
|
||||
}
|
||||
|
||||
// Draw empty portion
|
||||
for x in (bar_x + filled_width)..(bar_x + bar_width) {
|
||||
buf[(x, area.y)].set_char('─').set_style(self.empty_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical gauge (for volume, etc.)
|
||||
#[allow(dead_code)]
|
||||
pub struct VerticalBar {
|
||||
/// Value (0.0 to 1.0)
|
||||
value: f64,
|
||||
/// Filled style
|
||||
filled_style: Style,
|
||||
/// Empty style
|
||||
empty_style: Style,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl VerticalBar {
|
||||
pub fn new(value: f64) -> Self {
|
||||
Self {
|
||||
value: value.clamp(0.0, 1.0),
|
||||
filled_style: Style::default().bg(Color::Blue),
|
||||
empty_style: Style::default().bg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filled_style(mut self, style: Style) -> Self {
|
||||
self.filled_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn empty_style(mut self, style: Style) -> Self {
|
||||
self.empty_style = style;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for VerticalBar {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height < 1 || area.width < 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let filled_height = (area.height as f64 * self.value) as u16;
|
||||
let empty_start = area.y + area.height - filled_height;
|
||||
|
||||
// Draw empty portion (top)
|
||||
for y in area.y..empty_start {
|
||||
for x in area.x..(area.x + area.width) {
|
||||
buf[(x, y)].set_char('░').set_style(self.empty_style);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw filled portion (bottom)
|
||||
for y in empty_start..(area.y + area.height) {
|
||||
for x in area.x..(area.x + area.width) {
|
||||
buf[(x, y)].set_char('█').set_style(self.filled_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user