use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Row, Table}; use super::{art, theme}; use crate::api::models::{ArtistCard, ReleaseCard}; use crate::app::state::{ ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState, GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups, }; use crate::art::cache_key; pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { match state.global.stack.last() { None => draw_grid(frame, area, state), Some(GlobalView::Artist { id, cursor }) => draw_artist(frame, area, state, *id, *cursor), Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor), Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor), } } fn error_style() -> Style { Style::new().fg(Color::Red) } fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { let block = Block::bordered() .title(title) .title_style(theme::header()) .border_style(theme::dim()); let inner = block.inner(area); frame.render_widget(block, area); inner } fn centered_line(frame: &mut Frame, area: Rect, line: Line) { if area.height == 0 { return; } let middle = Rect { y: area.y + area.height / 2, height: 1, ..area }; frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle); } fn tile_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> { state .art .get(&cache_key(url?, ART_CELL_WIDTH, ART_CELL_HEIGHT)) } fn header_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> { state .art .get(&cache_key(url?, ART_HEADER_WIDTH, ART_HEADER_HEIGHT)) } fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) { match art_state { Some(ArtState::Ready(image)) => { frame.render_widget(Paragraph::new(art::to_text(image)), area); } Some(ArtState::Loading) => centered_line(frame, area, Line::styled("…", theme::dim())), _ => centered_line(frame, area, Line::styled("♪", theme::dim())), } } /// Bordered tile with artwork, a title line and a dim meta line. The /// selected tile gets a thick accent border and an inverted (filled) /// caption so it stands out in a large grid; the artwork stays untouched. fn draw_tile( frame: &mut Frame, tile: Rect, art_state: Option<&ArtState>, title: &str, meta: &str, selected: bool, ) { let block = if selected { Block::bordered() .border_type(ratatui::widgets::BorderType::Thick) .border_style(theme::accent()) } else { Block::bordered().border_style(theme::dim()) }; let inner = block.inner(tile); frame.render_widget(block, tile); let art_area = Rect { height: ART_CELL_HEIGHT.min(inner.height), ..inner }; draw_art(frame, art_area, art_state); if inner.height > ART_CELL_HEIGHT { let name_area = Rect { y: inner.y + ART_CELL_HEIGHT, height: 1, ..inner }; frame.render_widget(Paragraph::new(Line::raw(title.to_string())), name_area); if selected { frame.buffer_mut().set_style(name_area, theme::tab_active()); } } if inner.height > ART_CELL_HEIGHT + 1 { let meta_area = Rect { y: inner.y + ART_CELL_HEIGHT + 1, height: 1, ..inner }; frame.render_widget( Paragraph::new(Line::styled(meta.to_string(), theme::dim())), meta_area, ); if selected { frame.buffer_mut().set_style(meta_area, theme::tab_active()); } } } /// One selectable row: left content, optional right-aligned suffix, full-row /// highlight when selected. fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option, selected: bool) { frame.render_widget(Paragraph::new(line), area); if let Some(right) = right { frame.render_widget( Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right), area, ); } if selected { frame.buffer_mut().set_style(area, theme::tab_active()); } } // --------------------------------------------------------------------------- // Scrollable content plan: a vertical list of items with known heights; the // viewport is scrolled so the cursor's item stays centered. // --------------------------------------------------------------------------- enum PlanItem { /// Section header line. Header(String), Gap, /// Selectable track row; the payload is the cursor index it represents. Track { cursor_index: usize, }, /// One row of release tiles (display-order positions). TileRow(Vec), /// One release as a table row (display-order position). TableRow(usize), } impl PlanItem { fn height(&self) -> u16 { match self { PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. } | PlanItem::TableRow(_) => 1, PlanItem::TileRow(_) => TILE_HEIGHT, } } } fn scroll_offset(items: &[PlanItem], cursor_item: Option, viewport: u16) -> u16 { let total: u16 = items.iter().map(PlanItem::height).sum(); if total <= viewport { return 0; } let Some(cursor_item) = cursor_item else { return 0; }; let top: u16 = items[..cursor_item].iter().map(PlanItem::height).sum(); let center = top + items[cursor_item].height() / 2; center .saturating_sub(viewport / 2) .min(total.saturating_sub(viewport)) } // --------------------------------------------------------------------------- // Artist grid (stack root) // --------------------------------------------------------------------------- fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { let global = &state.global; let title = if global.total > 0 { format!(" Global — {} artists ", global.total) } else { " Global ".to_string() }; let inner = bordered(frame, area, title); if global.artists.is_empty() { let message = if let Some(error) = &global.error { Line::styled(error.clone(), error_style()) } else if global.loading { Line::styled("loading artists…", theme::dim()) } else { Line::styled("no artists in the library", theme::dim()) }; centered_line(frame, inner, message); return; } match global.view { ViewMode::Tiles => draw_grid_tiles(frame, inner, state), ViewMode::Table => draw_grid_table(frame, inner, state), } } fn artist_tile_meta(artist: &ArtistCard) -> String { format!("{} rel · {} trk", artist.release_count, artist.track_count) } fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { let global = &state.global; let columns = usize::from((inner.width / TILE_WIDTH).max(1)); let visible_rows = usize::from((inner.height / TILE_HEIGHT).max(1)); let selected_row = global.selected / columns; let first_row = (selected_row / visible_rows) * visible_rows; let first_index = first_row * columns; let last_index = (first_index + visible_rows * columns).min(global.artists.len()); for (offset, artist) in global.artists[first_index..last_index].iter().enumerate() { let index = first_index + offset; let tile = Rect { x: inner.x + (offset % columns) as u16 * TILE_WIDTH, y: inner.y + (offset / columns) as u16 * TILE_HEIGHT, width: TILE_WIDTH, height: TILE_HEIGHT, }; draw_tile( frame, tile, tile_art(state, artist.image_url.as_ref()), &artist.name, &artist_tile_meta(artist), index == global.selected, ); } } fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) { let global = &state.global; let visible_rows = usize::from(inner.height.saturating_sub(1).max(1)); let first = (global.selected / visible_rows) * visible_rows; let last = (first + visible_rows).min(global.artists.len()); let rows = global.artists[first..last] .iter() .enumerate() .map(|(offset, artist)| { let index = first + offset; let style = if index == global.selected { theme::tab_active() } else { Style::new() }; Row::new(vec![ artist.name.clone(), artist.release_count.to_string(), artist.track_count.to_string(), ]) .style(style) }); let table = Table::new( rows, [ Constraint::Min(24), Constraint::Length(9), Constraint::Length(7), ], ) .header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header())); frame.render_widget(table, inner); } // --------------------------------------------------------------------------- // Artist view // --------------------------------------------------------------------------- fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { let loadable = state.artist_views.get(&id); let name = match loadable { Some(Loadable::Ready(detail)) => detail.name.clone(), _ => "Artist".to_string(), }; let inner = bordered(frame, area, format!(" Global ▸ {name} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, Some(Loadable::Failed(error)) => { return centered_line(frame, inner, Line::styled(error.clone(), error_style())); } _ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())), }; let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); let [header_area, content_area] = Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); // Header: artwork left, metadata right. let [art_area, _, info_area] = Layout::horizontal([ Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), Constraint::Length(2), Constraint::Min(0), ]) .areas(header_area); draw_art( frame, Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, header_art(state, detail.image_url.as_ref()), ); let mut about = format!("{} releases", detail.releases.len()); if !detail.featured_tracks.is_empty() { about.push_str(&format!(" · appears on {}", detail.featured_tracks.len())); } let info = vec![ Line::default(), Line::styled(detail.name.clone(), theme::header()), Line::default(), Line::styled( format!( "{} tracks · {} plays", detail.total_track_count, detail.total_play_count ), theme::dim(), ), Line::styled(about, theme::dim()), ]; frame.render_widget(Paragraph::new(info), info_area); // Scrollable content: top tracks, releases grouped by type, then the // tracks this artist is featured on. let tracks = detail.top_tracks.len(); let releases_len = detail.releases.len(); let featured_len = detail.featured_tracks.len(); let mut items = Vec::new(); let mut cursor_item = None; if tracks + releases_len + featured_len == 0 { return centered_line( frame, content_area, Line::styled("nothing here yet", theme::dim()), ); } if tracks > 0 { items.push(PlanItem::Header("Top tracks".to_string())); for index in 0..tracks { if cursor == index { cursor_item = Some(items.len()); } items.push(PlanItem::Track { cursor_index: index, }); } items.push(PlanItem::Gap); } let columns = usize::from((content_area.width / TILE_WIDTH).max(1)); let mut position = 0; for (label, group) in release_groups(&detail.releases) { items.push(PlanItem::Header(format!("{label} ({})", group.len()))); match state.global.view { ViewMode::Tiles => { for chunk in group.chunks(columns) { let row: Vec = (position..position + chunk.len()).collect(); if row.contains(&(cursor.wrapping_sub(tracks))) { cursor_item = Some(items.len()); } items.push(PlanItem::TileRow(row)); position += chunk.len(); } } ViewMode::Table => { for _ in &group { if cursor == tracks + position { cursor_item = Some(items.len()); } items.push(PlanItem::TableRow(position)); position += 1; } } } items.push(PlanItem::Gap); } if featured_len > 0 { items.push(PlanItem::Header(format!("Appears on ({featured_len})"))); for index in 0..featured_len { let flat = tracks + releases_len + index; if cursor == flat { cursor_item = Some(items.len()); } items.push(PlanItem::Track { cursor_index: flat }); } items.push(PlanItem::Gap); } let display_order = crate::app::state::release_display_order(&detail.releases); render_plan( frame, content_area, state, &items, cursor_item, &mut |frame, rect, item| match item { PlanItem::Track { cursor_index } => { let (track, number) = if *cursor_index < tracks { (&detail.top_tracks[*cursor_index], cursor_index + 1) } else { let offset = cursor_index - tracks - releases_len; (&detail.featured_tracks[offset], offset + 1) }; super::track_row( frame, rect, state, track, number.to_string(), cursor == *cursor_index, ); } PlanItem::TileRow(row) => { for (column, position) in row.iter().enumerate() { let release = &detail.releases[display_order[*position]]; let tile = Rect { x: rect.x + column as u16 * TILE_WIDTH, y: rect.y, width: TILE_WIDTH .min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)), height: rect.height, }; if tile.width < 3 { break; } draw_tile( frame, tile, tile_art(state, release.cover_url.as_ref()), &release.title, &release_tile_meta(release), cursor == tracks + position, ); } } PlanItem::TableRow(position) => { let release = &detail.releases[display_order[*position]]; let year = release.year.map(|y| y.to_string()).unwrap_or_default(); draw_row( frame, rect, Line::from(vec![ Span::raw(release.title.clone()), Span::styled(format!(" {year}"), theme::dim()), ]), Some(format!("{} trk", release.track_count)), cursor == tracks + position, ); } _ => unreachable!("headers and gaps are rendered by render_plan"), }, ); } fn release_tile_meta(release: &ReleaseCard) -> String { match release.year { Some(year) => format!("{year} · {} trk", release.track_count), None => format!("{} trk", release.track_count), } } /// Render plan items into `area`, scrolled so the cursor item is visible. /// Headers and gaps are drawn here; everything else is delegated. fn render_plan( frame: &mut Frame, area: Rect, _state: &AppState, items: &[PlanItem], cursor_item: Option, draw_item: &mut dyn FnMut(&mut Frame, Rect, &PlanItem), ) { if area.height == 0 { return; } let offset = scroll_offset(items, cursor_item, area.height); let mut top: u16 = 0; for item in items { let height = item.height(); let item_top = top; top += height; if item_top < offset { continue; } let rel_y = item_top - offset; if rel_y >= area.height { break; } let rect = Rect { x: area.x, y: area.y + rel_y, width: area.width, height: height.min(area.height - rel_y), }; match item { PlanItem::Header(label) => frame.render_widget( Paragraph::new(Line::styled(label.clone(), theme::header())), rect, ), PlanItem::Gap => {} other => draw_item(frame, rect, other), } } } // --------------------------------------------------------------------------- // Release view // --------------------------------------------------------------------------- fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { let loadable = state.release_views.get(&id); let title = match loadable { Some(Loadable::Ready(detail)) => detail.title.clone(), _ => "Release".to_string(), }; let inner = bordered(frame, area, format!(" Global ▸ {title} ")); let detail = match loadable { Some(Loadable::Ready(detail)) => detail, Some(Loadable::Failed(error)) => { return centered_line(frame, inner, Line::styled(error.clone(), error_style())); } _ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())), }; let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); let [header_area, tracks_area] = Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); let [art_area, _, info_area] = Layout::horizontal([ Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), Constraint::Length(2), Constraint::Min(0), ]) .areas(header_area); draw_art( frame, Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, header_art(state, detail.cover_url.as_ref()), ); let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect(); let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default(); let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect(); let mut info = vec![ Line::default(), Line::styled(detail.title.clone(), theme::header()), Line::raw(artists.join(", ")), Line::default(), Line::styled( format!( "{}{year} · {} tracks", detail.release_type, detail.tracks.len() ), theme::dim(), ), ]; if !uploaders.is_empty() { info.push(Line::styled( format!("uploaded by {}", uploaders.join(", ")), theme::dim(), )); } frame.render_widget(Paragraph::new(info), info_area); // Track list with centered scrolling. let visible = usize::from(tracks_area.height.max(1)); let total = detail.tracks.len(); let first = cursor .saturating_sub(visible / 2) .min(total.saturating_sub(visible)); for (offset, track) in detail.tracks.iter().enumerate().skip(first).take(visible) { let rect = Rect { x: tracks_area.x, y: tracks_area.y + (offset - first) as u16, width: tracks_area.width, height: 1, }; let number = track .track_number .map(|n| n.to_string()) .unwrap_or_else(|| (offset + 1).to_string()); super::track_row(frame, rect, state, track, number, cursor == offset); } } // --------------------------------------------------------------------------- // Search view (driven by the `:/query` command) // --------------------------------------------------------------------------- fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { let search = &state.search; let mut title = format!(" Search: {} ", search.query); if search.loading { title.push_str("· searching… "); } let inner = bordered(frame, area, title); let Some(results) = &search.results else { let hint = if search.query.is_empty() { "type to search artists, releases and tracks" } else { "searching…" }; return centered_line(frame, inner, Line::styled(hint, theme::dim())); }; if results.len() == 0 { return centered_line(frame, inner, Line::styled("nothing found", theme::dim())); } // All rows are one line tall: (line, right column, cursor index). let mut rows: Vec<(Line, Option, Option)> = Vec::new(); let mut index = 0; if !results.artists.is_empty() { rows.push((Line::styled("Artists", theme::header()), None, None)); for artist in &results.artists { rows.push(( Line::raw(artist.name.clone()), Some(artist_tile_meta(artist)), Some(index), )); index += 1; } rows.push((Line::default(), None, None)); } if !results.releases.is_empty() { rows.push((Line::styled("Releases", theme::header()), None, None)); for release in &results.releases { rows.push(( Line::from(vec![ Span::raw(release.title.clone()), Span::styled(format!(" {}", release.release_type), theme::dim()), ]), Some(release_tile_meta(release)), Some(index), )); index += 1; } rows.push((Line::default(), None, None)); } if !results.tracks.is_empty() { rows.push((Line::styled("Tracks", theme::header()), None, None)); for track in &results.tracks { let heart = if state.likes.contains(&track.id) { Span::styled("♥ ", theme::accent()) } else { Span::raw(" ") }; rows.push(( Line::from(vec![ heart, Span::raw(track.title.clone()), Span::styled( format!(" {} · {}", track.artist_line(), track.release_title), theme::dim(), ), ]), Some(super::track_meta_suffix(track, true)), Some(index), )); index += 1; } } let cursor_row = rows .iter() .position(|(_, _, c)| *c == Some(cursor)) .unwrap_or(0); let visible = usize::from(inner.height.max(1)); let first = cursor_row .saturating_sub(visible / 2) .min(rows.len().saturating_sub(visible)); for (offset, (line, right, row_cursor)) in rows.into_iter().enumerate().skip(first).take(visible) { let rect = Rect { x: inner.x, y: inner.y + (offset - first) as u16, width: inner.width, height: 1, }; draw_row(frame, rect, line, right, row_cursor == Some(cursor)); } }