src/ui/panes/queue_pane.rs (10087B)
1 use crossterm::event::{KeyCode, KeyEvent, 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::fit_to_width;
12 use crate::ui::text::max_scroll;
13
14 const HOVER_BG: Color = Color::Indexed(238); // very dark gray
15
16 pub struct QueuePane {
17 pub scroll_offset: usize,
18 pub hover_row: Option<usize>,
19 }
20
21 impl QueuePane {
22 pub fn new() -> Self {
23 Self {
24 scroll_offset: 0,
25 hover_row: None,
26 }
27 }
28 }
29
30 /// Color for each audio format extension
31 fn format_color(ext: &str) -> Color {
32 match ext {
33 "FLAC" => Color::Green,
34 "M4A" | "AAC" | "MP4" | "ALAC" => Color::Cyan,
35 "MP3" => Color::Yellow,
36 "OGG" => Color::Magenta,
37 "WAV" | "WAVE" => Color::Blue,
38 _ => Color::White,
39 }
40 }
41
42 impl Pane for QueuePane {
43 fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
44 let count = app.queue.tracks.len();
45 let border_color = if focused {
46 theme.border_focused
47 } else {
48 theme.border_unfocused
49 };
50
51 let block = Block::default()
52 .borders(Borders::ALL)
53 .border_style(Style::default().fg(border_color))
54 .title_style(Style::default().fg(if focused {
55 theme.border_focused
56 } else {
57 theme.fg
58 }));
59
60 let inner = block.inner(area);
61 let inner_height = inner.height as usize;
62 let inner_width = inner.width as usize;
63
64 // Auto-scroll to keep selected_index visible
65 if count > 0 {
66 if app.queue.selected_index < self.scroll_offset {
67 self.scroll_offset = app.queue.selected_index;
68 }
69 if inner_height > 0 && app.queue.selected_index >= self.scroll_offset + inner_height {
70 self.scroll_offset = app.queue.selected_index - inner_height + 1;
71 }
72 // Keep the viewport full: the scroll handlers clamp to count - 1,
73 // which lets the list scroll until one row sits above a blank screen.
74 self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
75 }
76
77 // Column layout: prefix(2) + title(55%) + artist(45%) + ext(4) + gap(1) + dur(5) + trail(1)
78 let ext_col_width = 4;
79 let dur_col_width = 5;
80 let prefix_width = 2;
81 let fixed_width = prefix_width + 1 + ext_col_width + 1 + dur_col_width + 1;
82 let flex_total = inner_width.saturating_sub(fixed_width);
83 let title_max = (flex_total * 55 / 100).max(4);
84 let artist_max = flex_total.saturating_sub(title_max).max(4);
85 let has_scrollbar = count > inner_height;
86
87 let items: Vec<ListItem> = app
88 .queue
89 .tracks
90 .iter()
91 .enumerate()
92 .skip(self.scroll_offset)
93 .take(inner_height)
94 .map(|(i, &track_idx)| {
95 let track = &app.library.tracks[track_idx];
96 let is_current = app.queue.current_index == Some(i);
97 let is_selected = i == app.queue.selected_index;
98
99 let artist = track.display_artist();
100 let ext = track
101 .path
102 .extension()
103 .and_then(|e| e.to_str())
104 .unwrap_or("?")
105 .to_uppercase();
106 let dur = track.format_duration();
107
108 // Base styles
109 let sel_style = Style::default()
110 .bg(theme.highlight_bg)
111 .fg(theme.highlight_fg)
112 .add_modifier(Modifier::BOLD);
113 let cur_style = theme.current_track_style;
114 let normal_style = Style::default().fg(theme.fg);
115 let dim_style = Style::default().fg(Color::Gray);
116
117 let is_hovered = self.hover_row == Some(i);
118
119 let (title_style, artist_style, ext_style, dur_style, prefix_style) =
120 if is_selected && focused {
121 (sel_style, sel_style, sel_style, sel_style, sel_style)
122 } else if is_current {
123 let bg = if is_hovered { HOVER_BG } else { Color::Reset };
124 (
125 cur_style.bg(bg),
126 dim_style.bg(bg),
127 Style::default().fg(format_color(&ext)).add_modifier(Modifier::BOLD).bg(bg),
128 Style::default().fg(Color::DarkGray).bg(bg),
129 cur_style.bg(bg),
130 )
131 } else if is_hovered {
132 (
133 normal_style.bg(HOVER_BG),
134 dim_style.bg(HOVER_BG),
135 Style::default().fg(format_color(&ext)).bg(HOVER_BG),
136 Style::default().fg(Color::DarkGray).bg(HOVER_BG),
137 normal_style.bg(HOVER_BG),
138 )
139 } else {
140 (
141 normal_style,
142 dim_style,
143 Style::default().fg(format_color(&ext)),
144 Style::default().fg(Color::DarkGray),
145 normal_style,
146 )
147 };
148
149 let in_playlist = app.is_in_playlist(track_idx);
150 let prefix = if is_current { "\u{F04B} " } else { " " }; // nf-fa-play
151
152 // Star integrated into title text so it stays next to the title
153 let title_text = if in_playlist {
154 format!("{} \u{F005}", track.title) // "Title nf-fa-star"
155 } else {
156 track.title.clone()
157 };
158 let title_fitted = fit_to_width(&title_text, title_max);
159 let artist_fitted = fit_to_width(artist, artist_max);
160
161 // Right-align ext to ext_col_width
162 let ext_padded = format!("{:>width$}", ext, width = ext_col_width);
163 // Right-align dur to dur_col_width
164 let dur_padded = format!("{:>width$}", dur, width = dur_col_width);
165
166 // Row background for gap spans (keeps selection/hover highlight continuous)
167 let row_bg = if is_selected && focused {
168 sel_style
169 } else if is_hovered {
170 Style::default().bg(HOVER_BG)
171 } else {
172 Style::default()
173 };
174
175 ListItem::new(Line::from(vec![
176 Span::styled(prefix, prefix_style),
177 Span::styled(title_fitted, title_style),
178 Span::styled(artist_fitted, artist_style),
179 Span::styled(" ", row_bg),
180 Span::styled(ext_padded, ext_style),
181 Span::styled(" ", row_bg),
182 Span::styled(dur_padded, dur_style),
183 Span::styled(" ", row_bg),
184 ]))
185 })
186 .collect();
187
188 let list = List::new(items).block(block);
189 frame.render_widget(list, area);
190
191 // Scrollbar (rendered over the right border)
192 if has_scrollbar {
193 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
194 .begin_symbol(None)
195 .end_symbol(None);
196 let mut scrollbar_state = ScrollbarState::new(count)
197 .position(self.scroll_offset);
198 frame.render_stateful_widget(
199 scrollbar,
200 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
201 &mut scrollbar_state,
202 );
203 }
204 }
205
206 fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
207 let count = app.queue.tracks.len();
208 if count == 0 && key.code != KeyCode::Char('c') {
209 return None;
210 }
211
212 match key.code {
213 KeyCode::Char('j') | KeyCode::Down => None,
214 KeyCode::Char('k') | KeyCode::Up => None,
215 KeyCode::Enter => {
216 if count > 0 {
217 Some(AppAction::PlayQueueIndex(app.queue.selected_index))
218 } else {
219 None
220 }
221 }
222 KeyCode::Char('d') | KeyCode::Delete => {
223 if count > 0 {
224 Some(AppAction::RemoveFromQueue(app.queue.selected_index))
225 } else {
226 None
227 }
228 }
229 KeyCode::Char('c') => Some(AppAction::ClearQueue),
230 _ => None,
231 }
232 }
233
234 fn handle_mouse(&mut self, event: MouseEvent, _area: Rect, app: &App) -> Option<AppAction> {
235 // Clicks are handled in handler.rs, which owns queue selection.
236 match event.kind {
237 MouseEventKind::ScrollDown => self.handle_scroll(false, app),
238 MouseEventKind::ScrollUp => self.handle_scroll(true, app),
239 _ => None,
240 }
241 }
242
243 fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
244 let count = app.queue.tracks.len();
245 if count == 0 {
246 return None;
247 }
248 if up {
249 self.scroll_offset = self.scroll_offset.saturating_sub(3);
250 let new_sel = app.queue.selected_index.saturating_sub(3);
251 Some(AppAction::SetQueueSelection(new_sel))
252 } else {
253 self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
254 let new_sel = (app.queue.selected_index + 3).min(count.saturating_sub(1));
255 Some(AppAction::SetQueueSelection(new_sel))
256 }
257 }
258 }