src/ui/panes/library_pane.rs (16435B)
1 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
2 use ratatui::layout::Rect;
3 use ratatui::style::{Color, Modifier, Style};
4 use ratatui::text::{Line, Span};
5 use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
6 use ratatui::Frame;
7
8 use crate::app::{App, AppAction};
9 use crate::ui::pane::Pane;
10 use crate::ui::theme::Theme;
11 use crate::ui::text::max_scroll;
12
13 /// An entry in the flattened library list
14 #[derive(Debug, Clone)]
15 enum LibraryEntry {
16 SectionHeader(String),
17 Separator,
18 AllTracks(usize),
19 PlaylistEntry { idx: usize, name: String, count: usize },
20 FavoriteDir(String),
21 Album { name: String, artist: String },
22 }
23
24 const HOVER_BG: Color = Color::Indexed(238);
25
26 /// Library browser for the Queue tab.
27 /// Shows 4 sections: Playlist, Directories, Albums.
28 pub struct LibraryPane {
29 pub selected: usize,
30 pub scroll_offset: usize,
31 pub hover_row: Option<usize>,
32 }
33
34 impl LibraryPane {
35 pub fn new() -> Self {
36 Self {
37 selected: 0,
38 scroll_offset: 0,
39 hover_row: None,
40 }
41 }
42
43 /// Rows currently on offer, so callers can tell a click on a row from a
44 /// click on the blank space below the list.
45 pub fn row_count(&self, app: &App) -> usize {
46 Self::build_entries(app).len()
47 }
48
49 fn build_entries(app: &App) -> Vec<LibraryEntry> {
50 let mut entries = Vec::new();
51
52 // --- Playlist ---
53 entries.push(LibraryEntry::SectionHeader("\u{F054} Playlist".into()));
54 entries.push(LibraryEntry::AllTracks(app.library.tracks.len()));
55 for (idx, pl) in app.playlists.iter().enumerate() {
56 entries.push(LibraryEntry::PlaylistEntry {
57 idx,
58 name: pl.name.clone(),
59 count: pl.tracks.len(),
60 });
61 }
62
63 entries.push(LibraryEntry::Separator);
64
65 // --- Directories ---
66 let dirs = app.library.directories();
67 entries.push(LibraryEntry::SectionHeader(format!(
68 "\u{F054} Directories ({})",
69 dirs.len()
70 )));
71 for d in dirs {
72 entries.push(LibraryEntry::FavoriteDir(d.clone()));
73 }
74
75 entries.push(LibraryEntry::Separator);
76
77 // --- Albums ---
78 let albums = app.library.albums();
79 entries.push(LibraryEntry::SectionHeader(format!(
80 "\u{F054} Albums ({})",
81 albums.len()
82 )));
83 for (album, artist) in albums {
84 entries.push(LibraryEntry::Album { name: album.clone(), artist: artist.clone() });
85 }
86
87 entries
88 }
89 }
90
91 impl Pane for LibraryPane {
92 fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
93 let entries = Self::build_entries(app);
94 let count = entries.len();
95 let border_color = if focused {
96 theme.border_focused
97 } else {
98 theme.border_unfocused
99 };
100
101 let block = Block::default()
102 .borders(Borders::ALL)
103 .border_style(Style::default().fg(border_color))
104 .title_style(Style::default().fg(if focused {
105 theme.border_focused
106 } else {
107 theme.fg
108 }));
109
110 let inner = block.inner(area);
111 let inner_height = inner.height as usize;
112
113 // Auto-scroll
114 if count > 0 {
115 if self.selected < self.scroll_offset {
116 self.scroll_offset = self.selected;
117 }
118 if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
119 self.scroll_offset = self.selected - inner_height + 1;
120 }
121 // Keep the viewport full: the scroll handlers clamp to count - 1,
122 // which lets the list scroll until one row sits above a blank screen.
123 self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
124 }
125
126 let has_scrollbar = count > inner_height;
127
128 let items: Vec<ListItem> = entries
129 .iter()
130 .enumerate()
131 .skip(self.scroll_offset)
132 .take(inner_height)
133 .map(|(i, entry)| {
134 let is_selected = i == self.selected;
135 let is_hovered = self.hover_row == Some(i);
136 let highlight = Style::default()
137 .bg(theme.highlight_bg)
138 .fg(theme.highlight_fg)
139 .add_modifier(Modifier::BOLD);
140 let hover_bg = if is_hovered && !(is_selected && focused) {
141 HOVER_BG
142 } else {
143 Color::Reset
144 };
145
146 match entry {
147 LibraryEntry::SectionHeader(text) => {
148 let style = if is_selected && focused {
149 highlight
150 } else {
151 Style::default()
152 .fg(Color::Cyan)
153 .add_modifier(Modifier::BOLD)
154 .bg(hover_bg)
155 };
156 ListItem::new(Line::from(Span::styled(text.as_str(), style)))
157 }
158 LibraryEntry::Separator => {
159 ListItem::new(Line::from(""))
160 }
161 LibraryEntry::AllTracks(track_count) => {
162 if is_selected && focused {
163 ListItem::new(Line::from(vec![
164 Span::styled(" \u{F001} ", highlight),
165 Span::styled("All Tracks", highlight),
166 Span::styled(format!(" ({})", track_count), highlight),
167 ]))
168 } else {
169 ListItem::new(Line::from(vec![
170 Span::styled(" \u{F001} ", Style::default().fg(Color::Cyan).bg(hover_bg)),
171 Span::styled("All Tracks", Style::default().fg(theme.fg).bg(hover_bg)),
172 Span::styled(
173 format!(" ({})", track_count),
174 Style::default().fg(Color::DarkGray).bg(hover_bg),
175 ),
176 ]))
177 }
178 }
179 LibraryEntry::PlaylistEntry { name, count, .. } => {
180 let icon = "\u{F005} "; // ★
181 if is_selected && focused {
182 ListItem::new(Line::from(vec![
183 Span::styled(format!(" {}", icon), highlight),
184 Span::styled(name.as_str(), highlight),
185 Span::styled(format!(" ({})", count), highlight),
186 ]))
187 } else {
188 ListItem::new(Line::from(vec![
189 Span::styled(format!(" {}", icon), Style::default().fg(Color::Yellow).bg(hover_bg)),
190 Span::styled(name.as_str(), Style::default().fg(theme.fg).bg(hover_bg)),
191 Span::styled(
192 format!(" ({})", count),
193 Style::default().fg(Color::DarkGray).bg(hover_bg),
194 ),
195 ]))
196 }
197 }
198 LibraryEntry::FavoriteDir(name) => {
199 if is_selected && focused {
200 ListItem::new(Line::from(vec![
201 Span::styled(" \u{F07B} ", highlight),
202 Span::styled(format!("{}/", name), highlight),
203 ]))
204 } else {
205 ListItem::new(Line::from(vec![
206 Span::styled(" \u{F07B} ", Style::default().fg(Color::Green).bg(hover_bg)),
207 Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(hover_bg)),
208 ]))
209 }
210 }
211 LibraryEntry::Album { name, artist } => {
212 let album_display = if name.is_empty() {
213 "Unknown Album"
214 } else {
215 name.as_str()
216 };
217 let artist_display = if artist.is_empty() {
218 ""
219 } else {
220 artist.as_str()
221 };
222
223 if is_selected && focused {
224 let mut spans = vec![
225 Span::styled(" \u{F0025} ", highlight),
226 Span::styled(album_display, highlight),
227 ];
228 if !artist_display.is_empty() {
229 spans.push(Span::styled(
230 format!(" {}", artist_display),
231 highlight,
232 ));
233 }
234 ListItem::new(Line::from(spans))
235 } else {
236 let mut spans = vec![
237 Span::styled(
238 " \u{F0025} ",
239 Style::default().fg(Color::Magenta).bg(hover_bg),
240 ),
241 Span::styled(album_display, Style::default().fg(theme.fg).bg(hover_bg)),
242 ];
243 if !artist_display.is_empty() {
244 spans.push(Span::styled(
245 format!(" {}", artist_display),
246 Style::default().fg(Color::Gray).bg(hover_bg),
247 ));
248 }
249 ListItem::new(Line::from(spans))
250 }
251 }
252 }
253 })
254 .collect();
255
256 let list = List::new(items).block(block);
257 frame.render_widget(list, area);
258
259 // Scrollbar
260 if has_scrollbar {
261 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
262 .begin_symbol(None)
263 .end_symbol(None);
264 let mut scrollbar_state = ScrollbarState::new(count)
265 .position(self.scroll_offset);
266 frame.render_stateful_widget(
267 scrollbar,
268 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
269 &mut scrollbar_state,
270 );
271 }
272 }
273
274 fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
275 let entries = Self::build_entries(app);
276 let count = entries.len();
277 if count == 0 {
278 return None;
279 }
280
281 match key.code {
282 KeyCode::Char('j') | KeyCode::Down => {
283 if self.selected < count - 1 {
284 self.selected += 1;
285 // Skip separators when navigating
286 if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
287 && self.selected < count - 1
288 {
289 self.selected += 1;
290 }
291 }
292 None
293 }
294 KeyCode::Char('k') | KeyCode::Up => {
295 if self.selected > 0 {
296 self.selected -= 1;
297 // Skip separators when navigating
298 if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
299 && self.selected > 0
300 {
301 self.selected -= 1;
302 }
303 }
304 None
305 }
306 KeyCode::Enter => {
307 if self.selected >= count {
308 return None;
309 }
310 match &entries[self.selected] {
311 LibraryEntry::SectionHeader(_) | LibraryEntry::Separator => None,
312 LibraryEntry::PlaylistEntry { idx, .. } => {
313 if let Some(pl) = app.playlists.get(*idx) {
314 if !pl.tracks.is_empty() {
315 return Some(AppAction::AddToQueue(pl.tracks.clone()));
316 }
317 }
318 None
319 }
320 LibraryEntry::AllTracks(_) => {
321 let indices: Vec<usize> = (0..app.library.tracks.len()).collect();
322 if !indices.is_empty() {
323 Some(AppAction::AddToQueue(indices))
324 } else {
325 None
326 }
327 }
328 LibraryEntry::FavoriteDir(dir_name) => {
329 let indices: Vec<usize> = app
330 .library
331 .tracks
332 .iter()
333 .enumerate()
334 .filter(|(_, t)| {
335 t.path
336 .parent()
337 .and_then(|p| p.file_name())
338 .and_then(|n| n.to_str())
339 == Some(dir_name.as_str())
340 })
341 .map(|(i, _)| i)
342 .collect();
343 if !indices.is_empty() {
344 Some(AppAction::AddToQueue(indices))
345 } else {
346 None
347 }
348 }
349 LibraryEntry::Album { name, artist } => {
350 let tracks = app.library.tracks_by_album(name, artist).to_vec();
351 if !tracks.is_empty() {
352 Some(AppAction::AddToQueue(tracks))
353 } else {
354 None
355 }
356 }
357 }
358 }
359 KeyCode::Home | KeyCode::Char('g') => {
360 self.selected = 0;
361 self.scroll_offset = 0;
362 None
363 }
364 KeyCode::End | KeyCode::Char('G') => {
365 self.selected = count.saturating_sub(1);
366 None
367 }
368 _ => None,
369 }
370 }
371
372 fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
373 let block = Block::default().borders(Borders::ALL);
374 let inner = block.inner(area);
375 let count = Self::build_entries(app).len();
376
377 match event.kind {
378 MouseEventKind::Down(MouseButton::Left) => {
379 if event.column >= inner.x
380 && event.column < inner.x + inner.width
381 && event.row >= inner.y
382 && event.row < inner.y + inner.height
383 {
384 let clicked = self.scroll_offset + (event.row - inner.y) as usize;
385 if clicked < count {
386 self.selected = clicked;
387 }
388 }
389 None
390 }
391 MouseEventKind::ScrollDown => self.handle_scroll(false, app),
392 MouseEventKind::ScrollUp => self.handle_scroll(true, app),
393 _ => None,
394 }
395 }
396
397 fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
398 let count = Self::build_entries(app).len();
399 if count == 0 {
400 return None;
401 }
402 if up {
403 self.scroll_offset = self.scroll_offset.saturating_sub(3);
404 self.selected = self.selected.saturating_sub(3);
405 } else {
406 self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
407 self.selected = (self.selected + 3).min(count.saturating_sub(1));
408 }
409 None
410 }
411 }