src/ui/widgets/playlist_modal.rs (6049B)
1 use ratatui::layout::{Constraint, Direction, Layout, Rect};
2 use ratatui::style::{Color, Modifier, Style};
3 use ratatui::text::{Line, Span};
4 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
5 use ratatui::Frame;
6
7 use crate::app::App;
8 use crate::ui::theme::Theme;
9 use crate::ui::text::centered_rect;
10
11 #[derive(Debug, Clone, PartialEq)]
12 pub enum PlaylistModalMode {
13 List,
14 Create,
15 Rename,
16 }
17
18 pub fn render_playlist_modal(
19 frame: &mut Frame,
20 area: Rect,
21 selected: usize,
22 mode: &PlaylistModalMode,
23 input: &str,
24 app: &App,
25 theme: &Theme,
26 ) {
27 let modal = centered_rect(45, 45, area);
28
29 frame.render_widget(Clear, modal);
30
31 let title = match mode {
32 PlaylistModalMode::List => " Playlist ",
33 PlaylistModalMode::Create => " New Playlist ",
34 PlaylistModalMode::Rename => " Rename Playlist ",
35 };
36
37 let block = Block::default()
38 .borders(Borders::ALL)
39 .border_style(Style::default().fg(Color::Yellow))
40 .title(title)
41 .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
42
43 let inner = block.inner(modal);
44 frame.render_widget(block, modal);
45
46 match mode {
47 PlaylistModalMode::Create | PlaylistModalMode::Rename => {
48 let chunks = Layout::default()
49 .direction(Direction::Vertical)
50 .constraints([
51 Constraint::Length(2),
52 Constraint::Length(1),
53 Constraint::Min(1),
54 ])
55 .split(inner);
56
57 let prompt = if *mode == PlaylistModalMode::Create { "Name:" } else { "New name:" };
58 let input_line = Line::from(vec![
59 Span::styled(
60 format!(" {} ", prompt),
61 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
62 ),
63 Span::styled(input, Style::default().fg(Color::White)),
64 Span::styled("_", Style::default().fg(Color::Yellow).add_modifier(Modifier::SLOW_BLINK)),
65 ]);
66 frame.render_widget(Paragraph::new(input_line), chunks[0]);
67
68 let hint = Line::from(Span::styled(
69 " Enter: confirm Esc: cancel",
70 Style::default().fg(Color::DarkGray),
71 ));
72 frame.render_widget(Paragraph::new(hint), chunks[2]);
73 }
74 PlaylistModalMode::List => {
75 if app.playlists.is_empty() {
76 let chunks = Layout::default()
77 .direction(Direction::Vertical)
78 .constraints([Constraint::Min(1), Constraint::Length(1)])
79 .split(inner);
80
81 frame.render_widget(
82 Paragraph::new(Line::from(Span::styled(
83 " No playlists. Press 'a' to create.",
84 Style::default().fg(Color::DarkGray),
85 ))),
86 chunks[0],
87 );
88 let hint = Line::from(Span::styled(
89 " a: add Esc: close ",
90 Style::default().fg(Color::DarkGray),
91 ));
92 frame.render_widget(Paragraph::new(hint), chunks[1]);
93 return;
94 }
95
96 let current_track_idx = app.queue.current_index
97 .and_then(|qi| app.queue.tracks.get(qi).copied());
98
99 // Scroll the selection into view. Without this the list was drawn
100 // from the top and simply clipped, so on a short terminal `j` walked
101 // the highlight off the bottom and Enter toggled a playlist the user
102 // could not see.
103 let viewport = (inner.height as usize).saturating_sub(1).max(1);
104 let scroll = selected.saturating_sub(viewport.saturating_sub(1));
105
106 let items: Vec<ListItem> = app
107 .playlists
108 .iter()
109 .enumerate()
110 .skip(scroll)
111 .take(viewport)
112 .map(|(i, pl)| {
113 let is_selected = i == selected;
114 let already_in = current_track_idx
115 .is_some_and(|ti| pl.tracks.contains(&ti));
116
117 let check = if already_in { "\u{F00C} " } else { " " };
118 let icon = "\u{F005} ";
119
120 let style = if is_selected {
121 Style::default()
122 .bg(theme.highlight_bg)
123 .fg(theme.highlight_fg)
124 .add_modifier(Modifier::BOLD)
125 } else {
126 Style::default().fg(theme.fg)
127 };
128
129 let check_style = if is_selected {
130 style
131 } else if already_in {
132 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
133 } else {
134 Style::default().fg(Color::DarkGray)
135 };
136
137 let count_style = if is_selected {
138 style
139 } else {
140 Style::default().fg(Color::DarkGray)
141 };
142
143 ListItem::new(Line::from(vec![
144 Span::styled(check, check_style),
145 Span::styled(icon, if is_selected { style } else { Style::default().fg(Color::Yellow) }),
146 Span::styled(&pl.name, style),
147 Span::styled(format!(" ({})", pl.tracks.len()), count_style),
148 ]))
149 })
150 .collect();
151
152 let hint = Line::from(Span::styled(
153 " Enter: toggle a: add d: delete r: rename Esc: close ",
154 Style::default().fg(Color::DarkGray),
155 ));
156
157 let list = List::new(items).block(
158 Block::default().title_bottom(hint)
159 );
160 frame.render_widget(list, inner);
161 }
162 }
163 }
164