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::{Theme, render_empty_state, str_width, styled_block, truncate};
use crate::app::{AppState, Focus, SidebarItem};

/// Columns a list row spends on its selection marker.
const MARKER: &str = "> ";
const MARKER_W: usize = 2;

/// Borders plus the single library row.
const LIBRARY_H: u16 = 3;

pub fn render_sidebar(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
    // different kinds of thing, so separate frames rather than one flattened
    // list; each is its own Tab stop
    let sections = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(LIBRARY_H), Constraint::Min(0)])
        .split(area);

    render_library(frame, state, sections[0]);
    render_playlists(frame, state, sections[1]);
}

fn render_library(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
    let t = &state.theme;
    let g = t.glyphs();
    let selected = state.focus == Focus::Library;
    let is_active = state.active_view == SidebarItem::Library;

    let block = styled_block(" Library ", selected, t).style(t.apply_sidebar_bg(Style::default()));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    // the marker is prepended below, so the label gets what is left
    let label = entry(
        &format!("{} All tracks", g.note),
        state.library.tracks.len(),
        (inner.width as usize).saturating_sub(MARKER_W),
    );
    // a paragraph, not a list, so it applies the selection style itself
    let mut style = row_style(t, is_active, selected);
    if selected {
        style = style.patch(t.selection_style());
    }
    frame.render_widget(
        Paragraph::new(Line::from(Span::styled(
            format!("{}{label}", if selected { MARKER } else { "  " }),
            style,
        ))),
        inner,
    );
}

fn render_playlists(frame: &mut Frame<'_>, state: &AppState, area: Rect) {
    let t = &state.theme;
    let focused = state.focus == Focus::Playlists;
    let selected_row = focused.then_some(state.playlist_cursor);

    let block = styled_block(" Playlists ", selected_row.is_some(), t)
        .style(t.apply_sidebar_bg(Style::default()));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    if state.library.playlists.is_empty() {
        render_empty_state(frame, inner, "No playlists yet", "c", "to create one", t);
        return;
    }

    let width = (inner.width as usize).saturating_sub(MARKER_W);
    let items: Vec<ListItem<'_>> = state
        .library
        .playlists
        .iter()
        .enumerate()
        .map(|(i, pl)| {
            ListItem::new(entry(&pl.name, pl.tracks.len(), width)).style(row_style(
                t,
                state.active_view == SidebarItem::Playlist(pl.id),
                selected_row == Some(i),
            ))
        })
        .collect();

    let mut list_state = ListState::default();
    list_state.select(selected_row);

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

fn row_style(t: &Theme, is_active: bool, is_cursor: bool) -> Style {
    if is_active && is_cursor {
        Style::default().fg(t.accent).add_modifier(Modifier::BOLD)
    } else if is_cursor {
        Style::default().fg(t.text).add_modifier(Modifier::BOLD)
    } else if is_active {
        Style::default().fg(t.accent)
    } else {
        Style::default().fg(t.text_dim)
    }
}

/// Name left, track count flush right, so counts line up in a column instead
/// of trailing each name raggedly. The count is laid out first and never
/// truncated, so it eats into the name rather than overflowing the row.
fn entry(name: &str, count: usize, width: usize) -> String {
    let count = count.to_string();
    // ASCII digits, so width equals length
    let room = width.saturating_sub(count.len() + 1);
    if room == 0 {
        // narrower than the count plus a separator: the count is the more
        // useful of the two
        return truncate(&count, width);
    }
    let name = truncate(name, room);
    let pad = width.saturating_sub(str_width(&name) + count.len());
    format!("{name}{blank:pad$}{count}", blank = "")
}