use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use ratatui::Frame;
use crate::app::state::Tab;
use crate::app::{App, AppAction};
use crate::ui::text::{fit_to_width, max_scroll};
use crate::ui::theme::Theme;
const HOVER_BG: Color = Color::Indexed(238);
const SCROLL_STEP: usize = 3;
pub struct ListRow {
pub icon: Option<(&'static str, Color)>,
pub text: String,
pub detail: Option<String>,
}
impl ListRow {
pub fn plain(text: String) -> Self {
Self { icon: None, text, detail: None }
}
pub fn with_detail(text: String, detail: String) -> Self {
Self { icon: None, text, detail: Some(detail) }
}
}
#[derive(Default)]
pub struct ListPane {
pub selected: usize,
pub scroll_offset: usize,
pub hover_row: Option<usize>,
}
impl ListPane {
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
focused: bool,
theme: &Theme,
rows: &[ListRow],
) {
let count = rows.len();
if self.selected >= count {
self.selected = count.saturating_sub(1);
}
let border_color = if focused {
theme.border_focused
} else {
theme.border_unfocused
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
let inner_height = inner.height as usize;
let inner_width = inner.width as usize;
if count > 0 && inner_height > 0 {
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
}
if self.selected >= self.scroll_offset + inner_height {
self.scroll_offset = self.selected - inner_height + 1;
}
self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
}
let highlight = Style::default()
.bg(theme.highlight_bg)
.fg(theme.highlight_fg)
.add_modifier(Modifier::BOLD);
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.skip(self.scroll_offset)
.take(inner_height)
.map(|(i, row)| {
let selected = i == self.selected && focused;
let hovered = self.hover_row == Some(i);
let bg = if hovered { HOVER_BG } else { Color::Reset };
let base = if selected {
highlight
} else {
Style::default().fg(theme.fg).bg(bg)
};
let dim = if selected {
highlight
} else {
Style::default().fg(Color::DarkGray).bg(bg)
};
let mut spans = Vec::new();
let mut used = 2;
spans.push(Span::styled(" ", base));
if let Some((icon, color)) = row.icon {
used += icon.chars().count() + 1;
let style = if selected {
highlight
} else {
Style::default().fg(color).bg(bg)
};
spans.push(Span::styled(format!("{icon} "), style));
}
match &row.detail {
Some(detail) => {
let detail_w = detail.chars().count() + 1;
let label_w = inner_width.saturating_sub(used + detail_w);
spans.push(Span::styled(fit_to_width(&row.text, label_w), base));
spans.push(Span::styled(format!(" {detail}"), dim));
}
None => spans.push(Span::styled(
fit_to_width(&row.text, inner_width.saturating_sub(used)),
base,
)),
}
ListItem::new(Line::from(spans))
})
.collect();
frame.render_widget(List::new(items).block(block), area);
if count > inner_height {
let mut state = ScrollbarState::new(count).position(self.scroll_offset);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
area.inner(Margin { vertical: 1, horizontal: 0 }),
&mut state,
);
}
}
pub fn handle_key(&mut self, key: KeyEvent, count: usize) -> bool {
if count == 0 {
return false;
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if self.selected < count - 1 {
self.selected += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
}
KeyCode::Home | KeyCode::Char('g') => {
self.selected = 0;
self.scroll_offset = 0;
}
KeyCode::End | KeyCode::Char('G') => {
self.selected = count - 1;
}
KeyCode::Enter => return true,
_ => {}
}
false
}
pub fn handle_mouse(&mut self, event: MouseEvent, area: Rect, count: usize) {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
let inner = Block::default().borders(Borders::ALL).inner(area);
if event.column >= inner.x
&& event.column < inner.x + inner.width
&& event.row >= inner.y
&& event.row < inner.y + inner.height
{
let clicked = self.scroll_offset + (event.row - inner.y) as usize;
if clicked < count {
self.selected = clicked;
}
}
}
MouseEventKind::ScrollDown => self.scroll(false, count, area.height as usize),
MouseEventKind::ScrollUp => self.scroll(true, count, area.height as usize),
_ => {}
}
}
pub fn scroll(&mut self, up: bool, count: usize, viewport: usize) {
if count == 0 {
return;
}
let inner_height = viewport.saturating_sub(2);
if up {
self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_STEP);
self.selected = self.selected.saturating_sub(SCROLL_STEP);
} else {
self.scroll_offset =
(self.scroll_offset + SCROLL_STEP).min(max_scroll(count, inner_height));
self.selected = (self.selected + SCROLL_STEP).min(count - 1);
}
}
}
pub fn row_count(app: &App, tab: Tab) -> usize {
match tab {
Tab::Artists => app.library.artists().len(),
Tab::Albums => app.library.albums().len(),
Tab::Genre => app.library.genres().len(),
Tab::Format => app.library.formats().len(),
Tab::Playlists => app.playlists.len(),
Tab::Queue | Tab::Directories => 0,
}
}
pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
match tab {
Tab::Artists => app
.library
.artists()
.iter()
.map(|a| ListRow::plain(a.clone()))
.collect(),
Tab::Albums => app
.library
.albums()
.iter()
.map(|(album, artist)| ListRow::with_detail(album.clone(), artist.clone()))
.collect(),
Tab::Genre => app
.library
.genres()
.iter()
.map(|g| ListRow::plain(g.clone()))
.collect(),
Tab::Format => app
.library
.formats()
.iter()
.map(|(fmt, n)| ListRow::with_detail(fmt.clone(), format!("({n})")))
.collect(),
Tab::Playlists => app
.playlists
.iter()
.map(|pl| ListRow {
icon: Some(("\u{F005}", Color::Yellow)),
text: pl.name.clone(),
detail: Some(format!("({})", pl.tracks.len())),
})
.collect(),
Tab::Queue | Tab::Directories => Vec::new(),
}
}
pub fn activate(app: &App, tab: Tab, selected: usize) -> Option<AppAction> {
let tracks = match tab {
Tab::Artists => app.library.tracks_by_artist(app.library.artists().get(selected)?).to_vec(),
Tab::Albums => {
let (album, artist) = app.library.albums().get(selected)?;
app.library.tracks_by_album(album, artist).to_vec()
}
Tab::Genre => app.library.tracks_by_genre(app.library.genres().get(selected)?).to_vec(),
Tab::Format => app
.library
.tracks_by_format(&app.library.formats().get(selected)?.0)
.to_vec(),
Tab::Playlists => app.playlists.get(selected)?.tracks.clone(),
Tab::Queue | Tab::Directories => return None,
};
(!tracks.is_empty()).then_some(AppAction::AddToQueue(tracks))
}