Krolyxon
Commit c847cda

rmptuisrcui.rs

489 lines · 15.6 KB · Rust

1use std::time::Duration;
2
3use crate::{
4 app::{App, PlaylistFocus, SelectedTab},
5 browser::BrowserEntry,
6 connection::{PlaybackState, Song, VolumeStatus},
7};
8use ratatui::{prelude::*, widgets::*};
9
10#[derive(Debug, PartialEq, Clone, Copy)]
11pub enum InputMode {
12 Editing,
13 Normal,
14 PlaylistRename,
15 NewPlaylist,
16}
17
18fn song_row(song: &Song, playing: bool, queued: bool) -> Row<'static> {
19 let mut artist = song.artist.clone().unwrap_or_default();
20 let track = tag_value(song, "Track");
21 let title = song.title.clone().unwrap_or_else(|| song.file.clone());
22 let album = tag_value(song, "Album");
23 let time = App::format_time(song.duration.unwrap_or(Duration::ZERO));
24
25 if playing {
26 artist.insert_str(0, "> ");
27 }
28 let row = Row::new(vec![
29 Cell::from(artist.cyan()),
30 Cell::from(if playing {
31 format!(" {track}").green()
32 } else {
33 track.green()
34 }),
35 Cell::from(if playing { format!(" {title}") } else { title }),
36 Cell::from(album.cyan()),
37 Cell::from(time.magenta()),
38 ]);
39 if queued {
40 row.bold()
41 } else {
42 row
43 }
44}
45
46fn tag_value(song: &Song, name: &str) -> String {
47 song.tags
48 .iter()
49 .find(|(tag, _)| tag.eq_ignore_ascii_case(name))
50 .map(|(_, value)| value.clone())
51 .unwrap_or_default()
52}
53
54/// Renders the user interface widgets
55pub fn render(app: &mut App, frame: &mut Frame) {
56 // Layout
57 let layout = Layout::default()
58 .direction(Direction::Vertical)
59 .constraints(vec![Constraint::Percentage(93), Constraint::Min(3)])
60 .split(frame.area());
61
62 match app.selected_tab {
63 SelectedTab::Queue => draw_queue(frame, app, layout[0]),
64 SelectedTab::Playlists => draw_playlist_viewer(frame, app, layout[0]),
65 SelectedTab::DirectoryBrowser => draw_directory_browser(frame, app, layout[0]),
66 }
67
68 match app.inputmode {
69 InputMode::Normal => {
70 draw_progress_bar(frame, app, layout[1]);
71 }
72 InputMode::Editing => {
73 draw_search_bar(frame, app, layout[1]);
74 }
75 InputMode::PlaylistRename => {
76 draw_rename_playlist(frame, app, layout[1]);
77 }
78 InputMode::NewPlaylist => {
79 draw_new_playlist(frame, app, layout[1]);
80 }
81 }
82
83 if app.playlist_popup {
84 draw_add_to_playlist(frame, app, layout[0]);
85 }
86}
87
88/// Draws the directory
89fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) {
90 let total_songs = app.conn.stats.songs.to_string();
91
92 let rows = app.browser.entries.iter().map(|entry| match entry {
93 BrowserEntry::Song(song) => {
94 let queued =
95 app.queue_list.list.iter().any(|queued| {
96 queued.file.rsplit('/').next().unwrap_or_default() == entry.name()
97 });
98 song_row(song, false, queued)
99 }
100 BrowserEntry::Directory(name) => directory_row(name),
101 });
102
103 let header = ["Artist", "Track", "Title", "Album", "Time"]
104 .into_iter()
105 .map(Cell::from)
106 .collect::<Row>()
107 .bold()
108 .height(1);
109 let table = Table::new(
110 rows,
111 [
112 Constraint::Percentage(34),
113 Constraint::Percentage(3),
114 Constraint::Min(30),
115 Constraint::Percentage(30),
116 Constraint::Percentage(3),
117 ],
118 )
119 .block(
120 Block::default()
121 .title(format!("Song Browser: {}", app.browser.path).bold())
122 .title(
123 Line::from(format!("Total Songs: {}", total_songs).green())
124 .alignment(Alignment::Center),
125 )
126 .title(match app.conn.volume_status {
127 VolumeStatus::Unmuted => {
128 Line::from(format!("Volume: {}%", app.conn.status.volume).green())
129 .alignment(Alignment::Right)
130 }
131 VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
132 })
133 .borders(Borders::ALL),
134 )
135 .row_highlight_style(
136 Style::default()
137 .add_modifier(Modifier::REVERSED)
138 .fg(Color::Cyan)
139 .bg(Color::Black),
140 )
141 .header(header)
142 .flex(layout::Flex::Legacy);
143
144 app.browser_state.select(Some(app.browser.selected));
145 frame.render_stateful_widget(table, size, &mut app.browser_state);
146}
147
148/// draws playing queue
149fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) {
150 let rows = app.queue_list.list.iter().enumerate().map(|(i, song)| {
151 let playing = app
152 .conn
153 .current_song
154 .place
155 .is_some_and(|position| i == position.pos as usize);
156 song_row(song, playing, false)
157 });
158
159 let header = ["Artist", "Track", "Title", "Album", "Time"]
160 .into_iter()
161 .map(Cell::from)
162 .collect::<Row>()
163 .bold()
164 .height(1);
165 let table = Table::new(
166 rows,
167 [
168 Constraint::Percentage(34),
169 Constraint::Percentage(3),
170 Constraint::Min(30),
171 Constraint::Percentage(30),
172 Constraint::Percentage(3),
173 ],
174 )
175 .block(
176 Block::default()
177 .title("Play Queue".green().bold())
178 .title(format!("({} items)", app.queue_list.list.len()).bold())
179 .title(match app.conn.volume_status {
180 VolumeStatus::Unmuted => {
181 Line::from(format!("Volume: {}%", app.conn.status.volume).green())
182 .alignment(Alignment::Right)
183 }
184 VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
185 })
186 .borders(Borders::ALL),
187 )
188 .row_highlight_style(
189 Style::default()
190 .add_modifier(Modifier::REVERSED)
191 .fg(Color::Cyan)
192 .bg(Color::Black),
193 )
194 .header(header)
195 .flex(layout::Flex::Legacy);
196
197 app.queue_state.select(Some(app.queue_list.index));
198 frame.render_stateful_widget(table, size, &mut app.queue_state);
199}
200
201// Draw search bar
202fn draw_search_bar(frame: &mut Frame, app: &mut App, size: Rect) {
203 // Make the cursor visible and ask ratatui to put it at the specified coordinates after
204 // rendering
205 #[allow(clippy::cast_possible_truncation)]
206 frame.set_cursor_position((
207 // Draw the cursor at the current position in the input field.
208 // This position is can be controlled via the left and right arrow key
209 size.x + app.search_cursor_pos as u16 + 2,
210 // Move one line down, from the border to the input line
211 size.y + 1,
212 ));
213
214 let input = Paragraph::new("/".to_string() + &app.search_input)
215 .style(Style::default())
216 .block(
217 Block::default()
218 .borders(Borders::ALL)
219 .title("Search: Enter to keep, n/N next/previous ".bold().green()),
220 );
221 frame.render_widget(input, size);
222}
223
224/// Draws Progress Bar
225fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) {
226 let song = app.conn.now_playing();
227
228 // Get the current playing state
229 let mut state = String::new();
230 if !app.queue_list.list.is_empty() {
231 state.push_str(match app.conn.status.state {
232 PlaybackState::Playing => "Playing",
233 PlaybackState::Paused => "Paused",
234 PlaybackState::Stopped => "Stopped",
235 });
236 state.push(':');
237 }
238
239 // Get the current modes
240 let mut modes_bottom: String = String::new();
241 // we do this to check if at least one mode is enabled so we can push "[]"
242 if app.conn.status.repeat | app.conn.status.random {
243 modes_bottom.push('r');
244 }
245
246 if !modes_bottom.is_empty() {
247 modes_bottom.clear();
248 modes_bottom.push('[');
249 if app.conn.status.repeat {
250 modes_bottom.push('r');
251 }
252 if app.conn.status.random {
253 modes_bottom.push('z');
254 }
255 modes_bottom.push(']');
256 };
257
258 // get the duration
259 let duration = if !app.conn.status.total.is_zero() {
260 format!(
261 "[{}/{}]",
262 App::format_time(app.conn.current_elapsed()),
263 App::format_time(app.conn.status.total)
264 )
265 } else {
266 "".to_string()
267 };
268
269 // Define the title
270 let title = Block::default()
271 .title(state.red().bold())
272 .title(song.green())
273 .title(Line::from(duration.cyan().bold()).alignment(Alignment::Right))
274 .title_bottom(modes_bottom)
275 .borders(Borders::ALL);
276
277 let progress_bar = LineGauge::default()
278 .block(title.borders(Borders::ALL))
279 .filled_style(
280 Style::default()
281 .fg(Color::Blue)
282 .add_modifier(Modifier::BOLD),
283 )
284 .unfilled_style(
285 Style::default()
286 .fg(Color::Black)
287 .add_modifier(Modifier::BOLD),
288 )
289 .filled_symbol(symbols::line::THICK_HORIZONTAL)
290 .unfilled_symbol(symbols::line::THICK_HORIZONTAL)
291 .ratio(app.conn.get_progress_ratio());
292
293 frame.render_widget(progress_bar, size);
294}
295
296fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) {
297 let layouts = Layout::default()
298 .direction(Direction::Horizontal)
299 .constraints(vec![Constraint::Percentage(40), Constraint::Percentage(60)])
300 .split(area);
301
302 // Draw list of playlists
303 let list_focused = app.playlist_focus == PlaylistFocus::List;
304 let title = Block::default()
305 .title("Playlists".green().bold())
306 .borders(Borders::ALL)
307 .border_style(if list_focused {
308 Style::default().fg(Color::Cyan)
309 } else {
310 Style::default()
311 });
312 let list = List::new(app.pl_list.list.iter().map(String::as_str))
313 .block(title)
314 .highlight_style(Style::new().fg(Color::Cyan).bg(Color::Black).add_modifier(
315 if list_focused {
316 Modifier::BOLD | Modifier::REVERSED
317 } else {
318 Modifier::BOLD
319 },
320 ))
321 .repeat_highlight_symbol(true);
322 app.playlists_state.select(Some(app.pl_list.index));
323 frame.render_stateful_widget(list, layouts[0], &mut app.playlists_state);
324
325 // Playlist viewer
326
327 // Handle if there are no playlists in the mpd database
328 if app.pl_list.list.is_empty() {
329 let title = Block::default()
330 .title("No Playlists Found".red().bold())
331 .title_alignment(Alignment::Center)
332 .borders(Borders::ALL);
333 frame.render_widget(Clear, area); //this clears out the background
334 frame.render_widget(title, area);
335 return;
336 }
337
338 let songs = &app.playlist_contents;
339 let rows = songs.iter().map(|song| {
340 let title = song.title.as_deref().unwrap_or_default().cyan();
341 let artist = song.artist.as_deref().unwrap_or(&song.file);
342 let time = App::format_time(song.duration.unwrap_or(Duration::ZERO));
343
344 let row = Row::new(vec![
345 Cell::from(artist),
346 Cell::from(title),
347 Cell::from(time.to_string().green()),
348 ]);
349 row
350 });
351
352 let title = Block::default()
353 .title(format!("Content: ({} items)", songs.len()).bold())
354 .borders(Borders::ALL)
355 .border_style(if app.playlist_focus == PlaylistFocus::Songs {
356 Style::default().fg(Color::Cyan)
357 } else {
358 Style::default()
359 });
360 let table = Table::new(
361 rows,
362 vec![
363 Constraint::Percentage(40),
364 Constraint::Percentage(50),
365 Constraint::Percentage(10),
366 ],
367 )
368 .block(title)
369 .row_highlight_style(
370 Style::default()
371 .add_modifier(Modifier::REVERSED)
372 .fg(Color::Cyan)
373 .bg(Color::Black),
374 )
375 .flex(layout::Flex::SpaceBetween);
376 app.playlist_songs_state
377 .select(Some(app.playlist_song_index));
378 frame.render_stateful_widget(table, layouts[1], &mut app.playlist_songs_state);
379}
380
381fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
382 let area = centered_rect(40, 50, area);
383 let mut state = ListState::default();
384 let title = Block::default()
385 .title("Add Selected Item to: ")
386 .title(Line::from("<Esc> to Cancel".green().bold()).alignment(Alignment::Right));
387 let list = List::new(app.append_list.list.iter().map(String::as_str))
388 .block(title.borders(Borders::ALL))
389 .highlight_style(
390 Style::new()
391 .fg(Color::Cyan)
392 .bg(Color::Black)
393 .add_modifier(Modifier::BOLD)
394 .add_modifier(Modifier::REVERSED),
395 )
396 .repeat_highlight_symbol(true);
397
398 state.select(Some(app.append_list.index));
399 frame.render_widget(Clear, area); //this clears out the background
400 frame.render_stateful_widget(list, area, &mut state);
401}
402
403fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
404 #[allow(clippy::cast_possible_truncation)]
405 frame.set_cursor_position((
406 // Draw the cursor at the current position in the input field.
407 // This position is can be controlled via the left and right arrow key
408 area.x + app.pl_cursor_pos as u16 + 2,
409 // Move one line down, from the border to the input line
410 area.y + 1,
411 ));
412
413 let input = Paragraph::new("/".to_string() + &app.pl_newname_input)
414 .style(Style::default())
415 .block(
416 Block::default()
417 .borders(Borders::ALL)
418 .title("Enter New Name: ".bold().green()),
419 );
420 frame.render_widget(input, area);
421}
422
423fn draw_new_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
424 #[allow(clippy::cast_possible_truncation)]
425 frame.set_cursor_position((
426 // Draw the cursor at the current position in the input field.
427 // This position is can be controlled via the left and right arrow key
428 area.x + app.pl_new_pl_cursor_pos as u16 + 2,
429 // Move one line down, from the border to the input line
430 area.y + 1,
431 ));
432
433 let input = Paragraph::new("/".to_string() + &app.pl_new_pl_input)
434 .style(Style::default())
435 .block(
436 Block::default()
437 .borders(Borders::ALL)
438 .title("Enter New Playlist's Name: ".bold().green()),
439 );
440 frame.render_widget(input, area);
441}
442
443fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
444 let popup_layout = Layout::vertical([
445 Constraint::Percentage((100 - percent_y) / 2),
446 Constraint::Percentage(percent_y),
447 Constraint::Percentage((100 - percent_y) / 2),
448 ])
449 .split(r);
450
451 Layout::horizontal([
452 Constraint::Percentage((100 - percent_x) / 2),
453 Constraint::Percentage(percent_x),
454 Constraint::Percentage((100 - percent_x) / 2),
455 ])
456 .split(popup_layout[1])[1]
457}
458
459fn directory_row(name: &str) -> Row<'static> {
460 Row::new(vec![Cell::from(format!("[{name}]")).column_span(5)])
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn directory_names_use_the_full_browser_row_width() {
469 let name = "mr-robot-vol1-long-name";
470 let area = Rect::new(0, 0, 80, 1);
471 let mut buffer = Buffer::empty(area);
472 let table = Table::new(
473 [directory_row(name)],
474 [
475 Constraint::Length(6),
476 Constraint::Length(2),
477 Constraint::Length(10),
478 Constraint::Length(10),
479 Constraint::Length(6),
480 ],
481 );
482 Widget::render(table, area, &mut buffer);
483
484 let rendered = (0..area.width)
485 .map(|x| buffer[(x, 0)].symbol())
486 .collect::<String>();
487 assert!(rendered.contains(&format!("[{name}]")));
488 }
489}