audium 2.0.0

A terminal music app
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{List, ListItem, ListState, Paragraph},
};

use super::layout::{
    Columns, GAP_S, NUM_W, Theme, cursor_spans_windowed, format_duration, row_marker, styled_block,
    truncate,
};
use crate::app::{AppState, Focus};

pub fn render_tracklist(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
    let focused = state.focus == Focus::TrackList;
    let t = &state.theme;
    let has_filter = state.filter_active || !state.tracklist_filter.is_empty();

    let title = format!(" {} ", state.active_view_name());
    let block = styled_block(&title, focused, t).style(t.apply_panel_bg(Style::default()));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    // list on top, filter bar beneath it when active
    let (list_rect, filter_rect) = if has_filter {
        let s = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Min(0), Constraint::Length(1)])
            .split(inner);
        (s[0], Some(s[1]))
    } else {
        (inner, None)
    };

    if let Some(fr) = filter_rect {
        render_filter_bar(frame, fr, state, t);
    }

    // -- Track list ------------------------------------------------------
    // outside the list: as list items they would scroll away with the content,
    // losing the labels exactly when a long library makes them useful
    let table = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1), // header
            Constraint::Length(1), // rule
            Constraint::Min(0),    // rows
        ])
        .split(list_rect);
    let (header_rect, rule_rect, rows_rect) = (table[0], table[1], table[2]);

    let cols = Columns::for_width(usize::from(list_rect.width));
    render_table_header(frame, header_rect, rule_rect, &cols, focused, t);

    let tracks = state.active_tracks();

    let items: Vec<ListItem<'_>> = tracks
        .iter()
        .map(|track| {
            let is_playing = state
                .now_playing
                .and_then(|np| state.queue.get(np))
                .is_some_and(|qt| qt.id == track.id);

            let marker = row_marker(is_playing, state.player.is_paused, state.elapsed(), t);
            let num = Span::styled(
                format!("{marker:>NUM_W$}{GAP_S}"),
                Style::default().fg(if is_playing { t.accent } else { t.subtle }),
            );

            let title_style = if is_playing {
                Style::default()
                    .fg(t.now_playing)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(t.text)
            };
            // three levels of depth: title, then artist, then the rest
            let artist_style = Style::default().fg(t.text_dim);
            let meta_style = Style::default().fg(t.subtle);

            let cells = cols.cells(
                &track.name,
                track.artist.as_deref().unwrap_or(""),
                track.album.as_deref().unwrap_or(""),
                &track
                    .duration_secs
                    .map_or_else(String::new, format_duration),
            );
            let mut spans = vec![num];
            for (n, cell) in cells.into_iter().enumerate() {
                spans.push(cell.style(match n {
                    0 => title_style,
                    1 => artist_style,
                    _ => meta_style,
                }));
            }
            ListItem::new(Line::from(spans))
        })
        .collect();

    let mut list_state = ListState::default();
    if focused {
        list_state.select(Some(state.tracklist_cursor));
    }

    frame.render_stateful_widget(
        List::new(items).highlight_style(t.selection_style()),
        rows_rect,
        &mut list_state,
    );
}

/// The `/` filter row, shown under the table while a filter is active.
fn render_filter_bar(frame: &mut Frame<'_>, area: Rect, state: &AppState, t: &Theme) {
    let prefix_style = if state.filter_active {
        Style::default().fg(t.accent).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(t.text_dim)
    };
    // "/ " already took two columns
    let field_w = usize::from(area.width).saturating_sub(2);
    let mut spans = vec![Span::styled("/ ", prefix_style)];
    if state.filter_active {
        // no in-place editing, so the cursor sits at the end
        spans.extend(cursor_spans_windowed(
            &state.tracklist_filter,
            state.tracklist_filter.len(),
            field_w,
            t,
        ));
    } else {
        spans.push(Span::styled(
            truncate(&state.tracklist_filter, field_w),
            Style::default().fg(t.text),
        ));
    }
    frame.render_widget(Paragraph::new(Line::from(spans)), area);
}

/// Column labels and the rule under them. Uppercased, so they read as a
/// heading rather than a first row of data, and accented while the panel has
/// focus so the eye lands on the active table.
fn render_table_header(
    frame: &mut Frame<'_>,
    header: Rect,
    rule: Rect,
    cols: &Columns,
    focused: bool,
    t: &Theme,
) {
    let label = if focused { t.accent } else { t.subtle };

    let mut spans = vec![Span::raw(format!("{blank:NUM_W$}{GAP_S}", blank = ""))];
    spans.extend(cols.cells("TITLE", "ARTIST", "ALBUM", "TIME"));
    frame.render_widget(
        Paragraph::new(Line::from(
            spans
                .into_iter()
                .map(|sp| sp.style(Style::default().fg(label).add_modifier(Modifier::BOLD)))
                .collect::<Vec<_>>(),
        )),
        header,
    );
    frame.render_widget(
        Paragraph::new(Span::styled(
            t.glyphs().rule.repeat(usize::from(rule.width)),
            Style::default().fg(label),
        )),
        rule,
    );
}