pulsedeck 0.2.1

A focused terminal internet radio player with fast search, saved stations, themes, visualizers, and resilient playback
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;

use super::theme;
use crate::app::{App, AppNotice, InputMode, LayoutMode, PlaybackState};

/// Render the bottom control bar: playback status + volume + keybinds.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Length(1)])
        .split(area);

    render_status_bar(frame, chunks[0], app);
    render_keybinds(frame, chunks[1], app);
}

/// Top row of controls: playback state + station + volume.
fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) {
    let mut spans: Vec<Span> = Vec::new();

    match (&app.playback, app.now_playing()) {
        (PlaybackState::Playing, Some(station)) => {
            status_chip(&mut spans, "", "PLAY", theme::playing());
            spans.push(Span::styled(station.name.as_str(), theme::cyan()));
            if let Some(ref track) = app.current_track {
                spans.push(Span::styled("", theme::playing()));
                spans.push(Span::styled(
                    track.as_str(),
                    Style::default()
                        .fg(theme::accent_secondary())
                        .add_modifier(Modifier::BOLD),
                ));
            }
        }
        (PlaybackState::FadingOut { .. }, Some(station)) => {
            status_chip(&mut spans, "", "FADE", Style::default().fg(theme::warm()));
            spans.push(Span::styled(station.name.as_str(), theme::dim()));
        }
        (PlaybackState::Paused, Some(station)) => {
            status_chip(&mut spans, "", "PAUSE", theme::neon());
            spans.push(Span::styled(station.name.as_str(), theme::dim()));
        }
        (PlaybackState::Connecting, _) => {
            status_chip(&mut spans, "", "TUNE", Style::default().fg(theme::warm()));
            spans.push(Span::styled(
                "Connecting...",
                Style::default().fg(theme::warm()),
            ));
        }
        (PlaybackState::Error(e), _) => {
            status_chip(&mut spans, "", "ERROR", theme::error());
            spans.push(Span::styled(e.as_str(), theme::error()));
        }
        _ => {
            status_chip(&mut spans, "", "STOP", theme::dim());
            spans.push(Span::styled("Select a station", theme::dim()));
        }
    }

    spans.push(Span::styled("", theme::dim()));
    spans.push(Span::styled(volume_label(app), theme::dim()));
    spans.push(Span::styled(volume_bar_fill(app), theme::vol_filled()));
    spans.push(Span::styled(volume_bar_empty(app), theme::vol_empty()));

    spans.push(Span::styled("", theme::dim()));
    spans.push(Span::styled(layout_label(app.layout_mode), theme::dim()));
    spans.push(Span::styled("  ", theme::dim()));
    spans.push(Span::styled(
        visualizer_label(app.visualizer_mode),
        theme::dim(),
    ));

    if let Some(ref notice) = app.notice {
        spans.push(Span::styled("", theme::dim()));
        match notice {
            AppNotice::Info(message) => {
                spans.push(Span::styled(message.as_str(), theme::playing()));
            }
            AppNotice::Error(message) => {
                spans.push(Span::styled("Save warning: ", theme::error()));
                spans.push(Span::styled(message.as_str(), theme::error()));
            }
        }
    }

    let line = Line::from(spans);
    let paragraph = Paragraph::new(vec![line]).style(Style::default().bg(theme::bg()));

    frame.render_widget(paragraph, area);
}

fn status_chip(spans: &mut Vec<Span>, icon: &'static str, label: &'static str, style: Style) {
    spans.push(Span::styled(" ", theme::dim()));
    spans.push(Span::styled(icon, style));
    spans.push(Span::styled(" ", theme::dim()));
    spans.push(Span::styled(label, style.add_modifier(Modifier::BOLD)));
    spans.push(Span::styled("  ", theme::dim()));
}

fn volume_label(app: &App) -> String {
    if app.muted {
        "VOL MUTE ".to_string()
    } else {
        format!("VOL {:>3}% ", app.volume)
    }
}

fn volume_bar_fill(app: &App) -> String {
    let filled = if app.muted {
        0
    } else {
        app.volume as usize / 5
    };

    "".repeat(filled)
}

fn volume_bar_empty(app: &App) -> String {
    let filled = if app.muted {
        0
    } else {
        app.volume as usize / 5
    };
    let empty = 20 - filled;

    "".repeat(empty)
}

fn layout_label(layout_mode: LayoutMode) -> &'static str {
    match layout_mode {
        LayoutMode::Split => "SPLIT VIEW",
        LayoutMode::LeftOnly => "LIBRARY FOCUS",
        LayoutMode::RightOnly => "SIGNAL FOCUS",
    }
}

fn visualizer_label(visualizer_mode: usize) -> &'static str {
    match visualizer_mode {
        0 => "RTA",
        1 => "REAL OSC",
        _ => "SIM OSC",
    }
}

/// Bottom row: keyboard shortcut hints, mode-aware.
fn render_keybinds(frame: &mut Frame, area: Rect, app: &App) {
    let paragraph = Paragraph::new(vec![footer_line(app)]).style(Style::default().bg(theme::bg()));

    frame.render_widget(paragraph, area);
}

fn footer_line(app: &App) -> Line<'static> {
    if app.show_help {
        return hint_line(
            &[("h/?/Esc/q", "Close help")],
            Some("full control reference"),
        );
    }
    if app.show_station_details {
        return hint_line(
            &[("i/Esc/q", "Close details")],
            Some("selected station info"),
        );
    }
    if app.show_recent_tracks {
        return hint_line(
            &[("g/Esc/q", "Close recent tracks")],
            Some("session track list"),
        );
    }

    match app.input_mode {
        InputMode::Search => hint_line(
            &[
                ("Space", "Audition"),
                ("Enter", "Save+Play"),
                ("Esc", "Back"),
                ("↑↓", "Results"),
            ],
            Some("worldwide station search"),
        ),
        InputMode::Normal => normal_mode_footer(app),
    }
}

fn normal_mode_footer(app: &App) -> Line<'static> {
    if matches!(app.playback, PlaybackState::Error(_)) {
        return hint_line(
            &[
                ("r", "Retry"),
                ("s", "Stop"),
                (",", "Audio Output"),
                ("/", "Search"),
            ],
            Some("recover playback"),
        );
    }

    if app.visible_count() == 0 {
        return hint_line(
            &[
                ("/", "Search"),
                (",", "Settings"),
                ("h", "Help"),
                ("q", "Quit"),
            ],
            Some("start by finding a station"),
        );
    }

    if app.notice.is_some() {
        return hint_line(
            &[
                ("u", "Undo"),
                ("Enter", "Play"),
                ("/", "Search"),
                ("h", "Help"),
            ],
            Some("last action available"),
        );
    }

    match app.playback {
        PlaybackState::Playing
        | PlaybackState::Paused
        | PlaybackState::Connecting
        | PlaybackState::FadingOut { .. } => hint_line(
            &[
                ("Space", "Pause/Stop"),
                ("s", "Stop"),
                ("+/-", "Volume"),
                ("v", "Visualizer"),
                ("i", "Details"),
                ("g", "Tracks"),
            ],
            Some("listening"),
        ),
        PlaybackState::Stopped | PlaybackState::Error(_) => hint_line(
            &[
                ("Enter", "Play"),
                ("/", "Search"),
                ("i", "Details"),
                ("b", "Layout"),
                (",", "Settings"),
                ("h", "Help"),
            ],
            None,
        ),
    }
}

fn hint_line(
    hints: &[(&'static str, &'static str)],
    suffix: Option<&'static str>,
) -> Line<'static> {
    let mut spans = Vec::new();

    for (idx, (key, label)) in hints.iter().enumerate() {
        if idx > 0 {
            spans.push(Span::styled("  ", theme::dim()));
        }
        spans.push(Span::styled(" [", theme::dim()));
        spans.push(Span::styled(*key, theme::cyan()));
        spans.push(Span::styled("] ", theme::dim()));
        spans.push(Span::styled(*label, theme::dim()));
    }

    if let Some(suffix) = suffix {
        spans.push(Span::styled("  ", theme::dim()));
        spans.push(Span::styled(
            suffix,
            Style::default()
                .fg(theme::accent_secondary())
                .add_modifier(Modifier::ITALIC),
        ));
    }

    Line::from(spans)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn layout_labels_use_user_facing_focus_terms() {
        assert_eq!(layout_label(LayoutMode::Split), "SPLIT VIEW");
        assert_eq!(layout_label(LayoutMode::LeftOnly), "LIBRARY FOCUS");
        assert_eq!(layout_label(LayoutMode::RightOnly), "SIGNAL FOCUS");
    }

    #[test]
    fn visualizer_labels_drop_scope_jargon() {
        assert_eq!(visualizer_label(0), "RTA");
        assert_eq!(visualizer_label(1), "REAL OSC");
        assert_eq!(visualizer_label(2), "SIM OSC");
    }
}