#![allow(dead_code)] use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{List, ListItem, ListState, Paragraph}, }; use crate::application::library_state::{LibraryFocus, LibraryState}; use crate::data::{Album, AlbumStatus, Artist, MonitorState}; use crate::domain::aggregates::artist_status; use crate::theme; use crate::ui::pane::{Pane, section_divider}; use crate::ui::progress_bar::progress_bar; fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) { if !monitored { return ('◌', Style::default().fg(theme::GRAY)); } match status { AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)), AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)), AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)), } } fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) { match state { MonitorState::Monitored => match status { AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)), AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)), AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)), AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)), }, MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)), MonitorState::Excluded => ('x', Style::default().fg(theme::RED)), MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)), } } fn track_icon(have: bool) -> (char, Style) { if have { ('✓', Style::default().fg(theme::GREEN)) } else { ('✗', Style::default().fg(theme::RED)) } } fn fmt_size(gb: f64) -> String { if gb >= 1.0 { format!("{:.1} GB", gb) } else { format!("{} MB", (gb * 1024.0).round() as u32) } } pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area); render_artists_pane(frame, chunks[0], state); render_detail_pane(frame, chunks[1], state); } fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let focused = state.focus == LibraryFocus::Artists; let artist_count = state.artists.len(); let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum(); let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum(); let footer = Line::from(vec![ Span::styled( format!("{} artists · {} alb", artist_count, total_albums), Style::default().fg(theme::GRAY), ), Span::raw(" "), Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)), ]); let artist_count_str = artist_count.to_string(); let pane = Pane::new("Artists") .meta(&artist_count_str) .focused(focused) .footer(footer); let block = pane.build_block(); let inner = block.inner(area); frame.render_widget(block, area); let items: Vec = state .artists .iter() .map(|artist| { let status = artist_status(artist); let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status); let total: u16 = artist.albums.iter().map(|a| a.total).sum(); let have: u16 = artist.albums.iter().map(|a| a.have).sum(); let mut name_text = artist.name.clone(); let count_str = format!("{}/{}", have, total); let name_width = inner.width as usize - 2 - count_str.len() - 2; if name_text.len() > name_width { name_text.truncate(name_width.saturating_sub(1)); name_text.push('…'); } let padding = name_width.saturating_sub(name_text.len()); Line::from(vec![ Span::styled(format!("{} ", icon_char), icon_style), Span::styled(name_text, Style::default().fg(theme::FG1)), Span::raw(" ".repeat(padding)), Span::styled(count_str, Style::default().fg(theme::GRAY)), ]) .into() }) .collect(); let highlight_style = if focused { Style::default().bg(theme::YELLOW).fg(theme::BG0) } else { Style::default().bg(theme::SELECT_BG).fg(theme::FG1) }; let list = List::new(items).highlight_style(highlight_style); frame.render_stateful_widget(list, inner, &mut state.artist_state); } fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks; let artist = state.selected_artist(); let meta = artist .map(|a| { format!( "{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or("") ) }) .unwrap_or_default(); let have_tracks: u16 = artist .map(|a| a.albums.iter().map(|al| al.have).sum()) .unwrap_or(0); let total_tracks: u16 = artist .map(|a| a.albums.iter().map(|al| al.total).sum()) .unwrap_or(0); let footer = if artist.is_some() { Line::from(vec![Span::styled( format!("{}/{} tracks", have_tracks, total_tracks), Style::default().fg(theme::GRAY), )]) } else { Line::from("") }; let pane = Pane::new("Detail") .meta(&meta) .focused(focused) .footer(footer); let block = pane.build_block(); let inner = block.inner(area); frame.render_widget(block, area); let Some(artist) = artist else { let msg = Paragraph::new(Span::styled( "No artist selected", Style::default().fg(theme::GRAY), )); frame.render_widget(msg, inner); return; }; let chunks = Layout::vertical([ Constraint::Length(6), Constraint::Length(1), Constraint::Percentage(40), Constraint::Length(1), Constraint::Fill(1), ]) .split(inner); render_artist_header(frame, chunks[0], artist); let albums_count = artist.albums.len(); let albums_label = format!("{} releases", albums_count); let album_divider = section_divider("albums", Some(&albums_label)); frame.render_widget(Paragraph::new(album_divider), chunks[1]); let selected_artist_idx = state.artist_state.selected(); if let Some(idx) = selected_artist_idx && let Some(artist) = state.artists.get(idx) { let albums = artist.albums.clone(); let focus = state.focus; render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state); } let album_title = state .selected_album() .map(|a| a.title.clone()) .unwrap_or_default(); let track_counts = state .selected_album() .map(|a| format!("{}/{}", a.have, a.total)) .unwrap_or_default(); let track_label = format!("tracks · {}", album_title); let track_divider = section_divider(&track_label, Some(&track_counts)); frame.render_widget(Paragraph::new(track_divider), chunks[3]); render_tracks_list(frame, chunks[4], state); } fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) { let have: u16 = artist.albums.iter().map(|a| a.have).sum(); let total: u16 = artist.albums.iter().map(|a| a.total).sum(); let (status_icon, status_text, status_style) = match artist.monitor_state { MonitorState::Monitored => ( Span::styled("● ", Style::default().fg(theme::GREEN)), "Monitored", Style::default().fg(theme::FG2), ), MonitorState::Unmonitored => ( Span::styled("◌ ", Style::default().fg(theme::GRAY)), "Unmonitored", Style::default().fg(theme::GRAY), ), MonitorState::Excluded => ( Span::styled("⊘ ", Style::default().fg(theme::RED)), "Excluded", Style::default().fg(theme::RED), ), MonitorState::Unspecified => ( Span::styled("? ", Style::default().fg(theme::GRAY)), "Unknown", Style::default().fg(theme::GRAY), ), }; let lines = vec![ Line::from(Span::styled( &artist.name, Style::default() .fg(theme::YELLOW) .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(vec![ Span::styled("status ", Style::default().fg(theme::GRAY)), status_icon, Span::styled(status_text, status_style), Span::raw(" "), Span::styled("path ", Style::default().fg(theme::GRAY)), Span::styled(&artist.path, Style::default().fg(theme::AQUA)), ]), Line::from(vec![ Span::styled("quality ", Style::default().fg(theme::GRAY)), Span::styled(&artist.quality, Style::default().fg(theme::FG1)), Span::raw(" "), Span::styled("size ", Style::default().fg(theme::GRAY)), Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)), ]), Line::from(vec![ Span::styled("albums ", Style::default().fg(theme::GRAY)), Span::styled( artist.albums.len().to_string(), Style::default().fg(theme::FG1), ), Span::raw(" "), Span::styled("tracks ", Style::default().fg(theme::GRAY)), Span::styled(have.to_string(), Style::default().fg(theme::FG1)), Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)), ]), ]; let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0)); frame.render_widget(paragraph, area); } fn render_albums_list( frame: &mut Frame, area: Rect, albums: &[Album], focus: LibraryFocus, album_state: &mut ListState, ) { let focused = focus == LibraryFocus::Albums; let items: Vec = albums .iter() .map(|album| { let (icon_char, icon_style) = status_icon(album.status, album.monitored); let type_str = format!("[{}]", album.album_type); let year_str = album.year.to_string(); let progress = progress_bar(album.have, album.total, 10, album.status); let count_str = format!("{}/{}", album.have, album.total); let quality_style = if album.quality == "—" { Style::default().fg(theme::GRAY) } else { Style::default().fg(theme::AQUA) }; let title_width = area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8; let mut title = album.title.clone(); if title.len() > title_width { title.truncate(title_width.saturating_sub(1)); title.push('…'); } let mut spans = vec![ Span::styled(format!("{} ", icon_char), icon_style), Span::styled(title, Style::default().fg(theme::FG1)), Span::raw(" "), Span::styled(type_str, Style::default().fg(theme::GRAY)), Span::raw(" "), Span::styled(year_str, Style::default().fg(theme::GRAY)), Span::raw(" "), ]; spans.extend(progress.spans); spans.push(Span::raw(" ")); spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY))); spans.push(Span::raw(" ")); spans.push(Span::styled(&album.quality, quality_style)); Line::from(spans).into() }) .collect(); let highlight_style = if focused { Style::default().bg(theme::YELLOW).fg(theme::BG0) } else { Style::default().bg(theme::SELECT_BG).fg(theme::FG1) }; let list = List::new(items).highlight_style(highlight_style); frame.render_stateful_widget(list, area, album_state); } fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) { let focused = state.focus == LibraryFocus::Tracks; if state.tracks.is_empty() { let msg = Paragraph::new(Span::styled( "(no album selected)", Style::default().fg(theme::GRAY), )); frame.render_widget(msg, area); return; } let items: Vec = state .tracks .iter() .map(|track| { let (icon_char, icon_style) = track_icon(track.have); let num_str = format!("{:02}", track.number); let title_style = if track.have { Style::default().fg(theme::FG1) } else { Style::default().fg(theme::GRAY) }; let quality_style = if track.have { Style::default().fg(theme::AQUA) } else { Style::default().fg(theme::RED) }; Line::from(vec![ Span::styled(format!("{} ", icon_char), icon_style), Span::styled(num_str, Style::default().fg(theme::GRAY)), Span::raw(" "), Span::styled(track.title.clone(), title_style), Span::raw(" "), Span::styled(track.duration.clone(), Style::default().fg(theme::GRAY)), Span::raw(" "), Span::styled(track.quality.clone(), quality_style), ]) .into() }) .collect(); let highlight_style = if focused { Style::default().bg(theme::YELLOW).fg(theme::BG0) } else { Style::default().bg(theme::SELECT_BG).fg(theme::FG1) }; let list = List::new(items).highlight_style(highlight_style); frame.render_stateful_widget(list, area, &mut state.track_state); }