plavno 0.1.1

A lightweight Linux terminal music player with service waves and library shuffle
use base64::{Engine as _, engine::general_purpose::STANDARD};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::{Paragraph, Widget, Wrap},
};
use std::io::{self, Write};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

struct Glyph {
    area: Rect,
    bytes: (usize, usize),
}

#[derive(Default)]
pub struct TextSelection {
    key: String,
    text: String,
    area: Rect,
    image: Option<Buffer>,
    glyphs: Vec<Glyph>,
    anchor: Option<usize>,
    selected: Option<(usize, usize)>,
}

impl TextSelection {
    pub fn clear(&mut self) {
        self.anchor = None;
        self.selected = None;
    }

    pub fn cancel_drag(&mut self) {
        self.anchor = None;
    }

    pub fn range(&self) -> Option<(usize, usize)> {
        self.selected
    }

    pub fn selected_text(&self) -> Option<&str> {
        self.selected.map(|(start, end)| &self.text[start..end])
    }

    pub fn height(&self, key: &str, text: &str, width: u16, limit: u16) -> u16 {
        if width == 0 || limit == 0 {
            return 0;
        }
        if self.key == key
            && self.text == text
            && self
                .image
                .as_ref()
                .is_some_and(|image| image.area.width == width)
            && self.complete()
        {
            return self.area.height.min(limit);
        }
        let mut image = Buffer::empty(Rect::new(0, 0, width, limit));
        Paragraph::new(text)
            .wrap(Wrap { trim: true })
            .render(image.area, &mut image);
        rendered_height(&image)
    }

    pub fn render(&mut self, key: &str, text: &str, area: Rect, buffer: &mut Buffer) -> u16 {
        if area.is_empty() {
            *self = Self::default();
            return 0;
        }
        if self.key != key
            || self.text != text
            || self.area.x != area.x
            || self.area.y != area.y
            || self.image.as_ref().is_none_or(|image| {
                image.area.width != area.width
                    || image.area.height != area.height
                        && !(self.complete() && self.area.height <= area.height)
            })
        {
            self.clear();
            self.key = key.into();
            self.text = text.into();
            let mut image = Buffer::empty(Rect::new(0, 0, area.width, area.height));
            Paragraph::new(text)
                .wrap(Wrap { trim: true })
                .render(image.area, &mut image);
            let height = rendered_height(&image);
            self.area = Rect::new(area.x, area.y, area.width, height);
            self.glyphs.clear();
            let mut source = text.grapheme_indices(true).peekable();
            for y in 0..height {
                let Some(last) = (0..area.width)
                    .rev()
                    .find(|&x| !image[(x, y)].symbol().trim().is_empty())
                else {
                    continue;
                };
                let mut x = 0;
                while x <= last {
                    let symbol = image[(x, y)].symbol();
                    while let Some(&(_, grapheme)) = source.peek() {
                        if grapheme == symbol {
                            break;
                        }
                        if grapheme.chars().all(char::is_whitespace)
                            || grapheme.width() == 0
                            || grapheme.width() > usize::from(area.width)
                        {
                            source.next();
                        } else {
                            break;
                        }
                    }
                    let width = symbol.width().max(1) as u16;
                    if let Some(&(start, grapheme)) = source.peek()
                        && grapheme == symbol
                    {
                        self.glyphs.push(Glyph {
                            area: Rect::new(x, y, width, 1),
                            bytes: (start, start + grapheme.len()),
                        });
                        source.next();
                    }
                    x += width;
                }
            }
            self.image = Some(image);
        }
        let image = self.image.as_ref().unwrap();
        let artist = text.find('\n').map_or(text.len(), |start| start + 1);
        for glyph in &self.glyphs {
            let style = if glyph.bytes.0 < artist {
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default()
                    .fg(Color::Gray)
                    .remove_modifier(Modifier::BOLD)
            };
            for x in glyph.area.x..glyph.area.right() {
                buffer[(area.x + x, area.y + glyph.area.y)]
                    .set_symbol(image[(x, glyph.area.y)].symbol())
                    .set_style(style);
            }
        }
        if let Some((start, end)) = self.selected {
            for glyph in &self.glyphs {
                if glyph.bytes.0 >= start && glyph.bytes.1 <= end {
                    for x in glyph.area.x..glyph.area.right() {
                        buffer[(area.x + x, area.y + glyph.area.y)]
                            .set_fg(Color::Black)
                            .set_bg(Color::Cyan);
                    }
                }
            }
        }
        self.area.height
    }

    fn complete(&self) -> bool {
        self.glyphs
            .last()
            .is_some_and(|glyph| glyph.bytes.1 == self.text.trim_end().len())
    }

    fn nearest(&self, column: u16, row: u16) -> Option<usize> {
        if row >= self.area.bottom() {
            return self.glyphs.len().checked_sub(1);
        }
        let y = row.saturating_sub(self.area.y);
        let x = column
            .saturating_sub(self.area.x)
            .min(self.area.width.saturating_sub(1));
        self.glyphs
            .iter()
            .rposition(|glyph| glyph.area.y < y || glyph.area.y == y && glyph.area.x <= x)
            .or((!self.glyphs.is_empty()).then_some(0))
    }

    fn extend(&mut self, end: usize) {
        if let Some(start) = self.anchor {
            self.selected = Some((
                self.glyphs[start.min(end)].bytes.0,
                self.glyphs[start.max(end)].bytes.1,
            ));
        }
    }

    pub fn handle(&mut self, mouse: MouseEvent) -> bool {
        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                self.clear();
                if self.area.contains((mouse.column, mouse.row).into()) {
                    self.anchor = self.nearest(mouse.column, mouse.row);
                    return self.anchor.is_some();
                }
            }
            MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
                if self.anchor.is_some() =>
            {
                if let Some(end) = self.nearest(mouse.column, mouse.row)
                    && (mouse.kind == MouseEventKind::Drag(MouseButton::Left)
                        || self.anchor != Some(end)
                        || self.selected.is_some())
                {
                    self.extend(end);
                }
                if mouse.kind == MouseEventKind::Up(MouseButton::Left) {
                    self.anchor = None;
                }
                return true;
            }
            MouseEventKind::Down(MouseButton::Right)
                if self.anchor.is_some() || self.selected.is_some() =>
            {
                self.clear();
                return true;
            }
            _ => {}
        }
        false
    }
}

fn rendered_height(image: &Buffer) -> u16 {
    (0..image.area.height)
        .rev()
        .find(|&y| (0..image.area.width).any(|x| !image[(x, y)].symbol().trim().is_empty()))
        .map_or(1, |y| y + 1)
}

pub fn copy_to_clipboard(mut writer: impl Write, text: &str) -> io::Result<()> {
    write!(writer, "\x1b]52;c;{}\x1b\\", STANDARD.encode(text))?;
    writer.flush()
}

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

    fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        }
    }

    #[test]
    fn wrapped_unicode_selection_keeps_exact_source_text_and_survives_redraw() {
        let area = Rect::new(3, 4, 9, 4);
        let title = "Cafe\u{301} 世界 longwordwrapped";
        let mut buffer = Buffer::empty(area);
        let mut selection = TextSelection::default();
        selection.render("track", title, area, &mut buffer);
        assert!(selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 3, 4)));
        assert!(selection.handle(mouse(MouseEventKind::Drag(MouseButton::Left), 100, 100)));
        assert!(selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 100, 100)));
        assert_eq!(selection.selected_text(), Some(title));
        selection.render("track", title, area, &mut buffer);
        assert_eq!(selection.selected_text(), Some(title));
        assert_eq!(buffer[(3, 4)].bg, Color::Cyan);
        assert!(selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 11, 4)));
        assert!(selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 8, 4)));
        assert_eq!(selection.selected_text(), Some("世界"));
        selection.render("next", title, area, &mut buffer);
        assert_eq!(selection.selected_text(), None);
    }

    #[test]
    fn simple_click_does_not_select_and_other_controls_receive_their_gestures() {
        let area = Rect::new(1, 1, 20, 3);
        let mut selection = TextSelection::default();
        selection.render("track", "A track name", area, &mut Buffer::empty(area));
        assert!(selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 3, 1)));
        assert!(selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 3, 1)));
        assert_eq!(selection.selected_text(), None);
        assert!(!selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 3, 5)));
        assert!(!selection.handle(mouse(MouseEventKind::Drag(MouseButton::Left), 3, 1)));
        assert!(!selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 3, 1)));
        assert!(!selection.handle(mouse(MouseEventKind::ScrollUp, 3, 1)));
    }

    #[test]
    fn artist_selection_and_dragging_across_track_fields_preserve_unicode_and_newlines() {
        let area = Rect::new(2, 3, 12, 4);
        let artist = "Cafe\u{301} 世界";
        let text = format!("Track\n{artist}");
        let mut buffer = Buffer::empty(area);
        let mut selection = TextSelection::default();
        selection.render("track", &text, area, &mut buffer);
        assert!(selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 2, 4)));
        assert!(selection.handle(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 4)));
        assert!(selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 30, 4)));
        assert_eq!(selection.selected_text(), Some(artist));
        selection.render("track", &text, area, &mut buffer);
        assert_eq!(selection.selected_text(), Some(artist));
        assert_eq!(buffer[(2, 4)].bg, Color::Cyan);
        assert_ne!(buffer[(2, 3)].bg, Color::Cyan);
        assert!(selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 2, 3)));
        assert!(selection.handle(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 4)));
        assert!(selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 30, 4)));
        assert_eq!(selection.selected_text(), Some(text.as_str()));
        let mut copied = Vec::new();
        copy_to_clipboard(&mut copied, selection.selected_text().unwrap()).unwrap();
        let payload = &copied[b"\x1b]52;c;".len()..copied.len() - 2];
        assert_eq!(STANDARD.decode(payload).unwrap(), text.as_bytes());
    }

    #[test]
    fn clipboard_payload_is_exact_utf8_and_cannot_inject_terminal_commands() {
        let text = "Track 世界\u{1b}]52;c;injected\u{7}";
        let mut output = Vec::new();
        copy_to_clipboard(&mut output, text).unwrap();
        let output = String::from_utf8(output).unwrap();
        let payload = output
            .strip_prefix("\u{1b}]52;c;")
            .unwrap()
            .strip_suffix("\u{1b}\\")
            .unwrap();
        assert_eq!(STANDARD.decode(payload).unwrap(), text.as_bytes());
        assert!(!payload.contains(['\u{1b}', '\u{7}']));
    }

    #[test]
    fn title_and_artist_overlay_preserves_artwork_and_wave_outside_the_text() {
        let area = Rect::new(2, 3, 16, 4);
        let mut buffer = Buffer::empty(area);
        for cell in &mut buffer.content {
            cell.set_char('⠿')
                .set_fg(Color::Magenta)
                .set_bg(Color::Rgb(12, 24, 36));
        }
        let mut selection = TextSelection::default();
        selection.render("track", "Track\nArtist", area, &mut buffer);
        assert_eq!(buffer[(2, 3)].symbol(), "T");
        assert!(buffer[(2, 3)].modifier.contains(Modifier::BOLD));
        assert_eq!(buffer[(2, 4)].symbol(), "A");
        assert_eq!(buffer[(2, 4)].fg, Color::Gray);
        assert!(
            buffer
                .content
                .iter()
                .all(|cell| cell.bg == Color::Rgb(12, 24, 36))
        );
        assert_eq!(buffer[(12, 3)].symbol(), "⠿");
        assert_eq!(buffer[(12, 3)].fg, Color::Magenta);
        assert_eq!(buffer[(2, 5)].symbol(), "⠿");
    }

    #[test]
    fn compact_height_respects_word_wrapping_and_keeps_visible_artist_selection() {
        let text = "aaaa aaaa aaaa\nArtist";
        let mut selection = TextSelection::default();
        assert_eq!(selection.height("track", text, 7, 10), 4);
        let area = Rect::new(2, 3, 7, 10);
        selection.render("track", text, area, &mut Buffer::empty(area));
        selection.handle(mouse(MouseEventKind::Down(MouseButton::Left), 2, 6));
        selection.handle(mouse(MouseEventKind::Up(MouseButton::Left), 8, 6));
        assert_eq!(selection.selected_text(), Some("Artist"));
        let compact = Rect::new(2, 3, 7, 4);
        selection.render("track", text, compact, &mut Buffer::empty(compact));
        assert_eq!(selection.selected_text(), Some("Artist"));
        assert_eq!(selection.height("track", text, 7, 10), 4);
        let clipped = Rect::new(2, 3, 7, 2);
        selection.render("track", text, clipped, &mut Buffer::empty(clipped));
        assert_eq!(selection.height("track", text, 7, 10), 4);
    }
}