plavno 0.4.0

A lightweight terminal music player for Linux, macOS and Windows with service waves and library shuffle
use crate::backend::WaveStyle;
use crossterm::event::{Event, KeyCode, KeyEventKind, MouseButton, MouseEventKind};
use ratatui::{
    Frame,
    layout::{Constraint, Layout, Rect},
    style::{Color, Style},
    widgets::{Block, List, ListItem, ListState, Paragraph, Wrap},
};

pub enum Action {
    Close,
    Apply(WaveStyle),
}

pub struct FlowView {
    source: String,
    name: &'static str,
    state: ListState,
    area: Rect,
    pressed: Option<usize>,
}

impl FlowView {
    pub fn new(source: &str, name: &'static str, style: WaveStyle) -> Self {
        Self {
            source: source.into(),
            name,
            state: ListState::default()
                .with_selected(WaveStyle::ALL.iter().position(|s| *s == style)),
            area: Rect::default(),
            pressed: None,
        }
    }

    pub fn cancel_press(&mut self) {
        self.pressed = None;
    }

    fn move_by(&mut self, delta: isize) {
        self.state.select(Some(
            self.state
                .selected()
                .unwrap_or(0)
                .saturating_add_signed(delta)
                .min(3),
        ));
    }

    pub fn handle(&mut self, event: Event) -> Option<Action> {
        match event {
            Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
                match key.code {
                    KeyCode::Esc | KeyCode::Char('q' | 'g') => return Some(Action::Close),
                    KeyCode::Char(c @ '1'..='4') => {
                        return Some(Action::Apply(WaveStyle::ALL[c as usize - '1' as usize]));
                    }
                    KeyCode::Enter => {
                        return Some(Action::Apply(
                            WaveStyle::ALL[self.state.selected().unwrap_or(0)],
                        ));
                    }
                    KeyCode::Up => self.move_by(-1),
                    KeyCode::Down => self.move_by(1),
                    _ => {}
                }
            }
            Event::Mouse(mouse) => {
                let index = self
                    .area
                    .contains((mouse.column, mouse.row).into())
                    .then(|| self.state.offset() + usize::from(mouse.row - self.area.y))
                    .filter(|index| *index < WaveStyle::ALL.len());
                match mouse.kind {
                    MouseEventKind::Down(MouseButton::Right) => return Some(Action::Close),
                    MouseEventKind::Down(MouseButton::Left) => {
                        self.pressed = index;
                        if let Some(index) = index {
                            self.state.select(Some(index));
                        }
                    }
                    MouseEventKind::Up(MouseButton::Left) => {
                        if let Some(pressed) = self.pressed.take()
                            && index == Some(pressed)
                        {
                            return Some(Action::Apply(WaveStyle::ALL[pressed]));
                        }
                    }
                    MouseEventKind::ScrollUp => self.move_by(-1),
                    MouseEventKind::ScrollDown => self.move_by(1),
                    _ => {}
                }
            }
            Event::Resize(_, _) | Event::FocusLost => self.cancel_press(),
            _ => {}
        }
        None
    }

    pub fn draw(&mut self, frame: &mut Frame) {
        let rows = Layout::vertical([
            Constraint::Length(3),
            Constraint::Length(6),
            Constraint::Min(0),
        ])
        .margin(1)
        .split(frame.area());
        frame.render_widget(
            Paragraph::new("1–4 choose · ↑/↓ select · Enter apply · Esc back").block(
                Block::bordered().title(format!(
                    " plavno {} · Flow · {} ",
                    crate::VERSION,
                    self.name
                )),
            ),
            rows[0],
        );
        let block = Block::bordered();
        self.area = block.inner(rows[1]);
        let items: Vec<_> = WaveStyle::ALL
            .iter()
            .enumerate()
            .map(|(index, style)| {
                ListItem::new(format!(
                    " {}  {:<10} {}",
                    index + 1,
                    style.label(),
                    style.description(&self.source)
                ))
            })
            .collect();
        frame.render_stateful_widget(
            List::new(items)
                .block(block)
                .highlight_style(Style::default().fg(Color::Black).bg(Color::Cyan)),
            rows[1],
            &mut self.state,
        );
        let help = if self.source == "mixed" {
            "Applying restarts the mix. Sources without Flow support keep playing without mood filters. The mix preset is saved separately."
        } else {
            "Click a preset to apply it. Applying starts a new wave; settings are saved per source."
        };
        frame.render_widget(
            Paragraph::new(help)
                .wrap(Wrap { trim: true })
                .style(Style::default().fg(Color::Gray)),
            rows[2],
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyEvent, KeyModifiers, MouseEvent};

    #[test]
    fn flow_presets_support_keys_and_matching_mouse_press_release() {
        let mut view = FlowView::new("vk", "VK Music", WaveStyle::Balanced);
        view.area = Rect::new(2, 4, 60, 4);
        let mouse = |kind, column, row| {
            Event::Mouse(MouseEvent {
                kind,
                column,
                row,
                modifiers: KeyModifiers::NONE,
            })
        };
        assert!(
            view.handle(mouse(MouseEventKind::Down(MouseButton::Left), 5, 7))
                .is_none()
        );
        assert!(matches!(
            view.handle(mouse(MouseEventKind::Up(MouseButton::Left), 5, 7)),
            Some(Action::Apply(WaveStyle::Work))
        ));
        view.handle(mouse(MouseEventKind::Down(MouseButton::Left), 5, 4));
        view.cancel_press();
        assert!(
            view.handle(mouse(MouseEventKind::Up(MouseButton::Left), 5, 4))
                .is_none()
        );
        assert!(matches!(
            view.handle(Event::Key(KeyEvent::new(
                KeyCode::Char('3'),
                KeyModifiers::NONE
            ))),
            Some(Action::Apply(WaveStyle::Energetic))
        ));
    }
}