Krolyxon
Commit 1dcac38

rmptuisrcscreensbrowser.rs

325 lines · 11.1 KB · Rust

1//! Screen 2: the directory browser. Stored playlists appear at the bottom of
2//! the root directory.
3
4use std::collections::{HashMap, HashSet};
5
6use crossterm::event::MouseEvent;
7use ratatui::{
8 Frame,
9 layout::{Constraint, Layout, Rect},
10 style::{Modifier, Style},
11 text::{Line, Span},
12 widgets::{Cell, Row},
13};
14
15use super::{Ctx, Screen};
16use crate::{
17 action::Action,
18 mpd::{DirEntry, MpdEvent, Query, Reply, Request, Selection, Subsystem, commands},
19 popups::AddToPlaylistPopup,
20 ui::{
21 self,
22 list::{Click, Header, ListView},
23 },
24};
25
26#[derive(Debug, Default)]
27pub struct BrowserScreen {
28 path: String,
29 entries: Vec<DirEntry>,
30 loaded: bool,
31 /// Parent directories and the cursor position in each.
32 stack: Vec<(String, usize)>,
33 /// The cursor position to restore once the directory has loaded.
34 restore: Option<usize>,
35 list: ListView,
36 marks: HashSet<usize>,
37 cache: HashMap<String, Vec<DirEntry>>,
38}
39
40impl BrowserScreen {
41 fn load(&mut self, ctx: &Ctx) {
42 if let Some(entries) = self.cache.get(&self.path) {
43 self.entries = entries.clone();
44 self.loaded = true;
45 self.apply_restore();
46 } else {
47 self.loaded = false;
48 ctx.client.query(Query::Directory(self.path.clone()));
49 }
50 }
51
52 fn apply_restore(&mut self) {
53 if let Some(index) = self.restore.take() {
54 self.list.select(index, self.entries.len());
55 }
56 }
57
58 fn open(&mut self, path: String, ctx: &Ctx) {
59 let index = self.list.selected(self.entries.len()).unwrap_or(0);
60 self.stack
61 .push((std::mem::replace(&mut self.path, path), index));
62 self.list.reset();
63 self.marks.clear();
64 self.load(ctx);
65 }
66
67 fn back(&mut self, ctx: &Ctx) {
68 if let Some((parent, index)) = self.stack.pop() {
69 self.path = parent;
70 self.list.reset();
71 self.marks.clear();
72 self.restore = Some(index);
73 self.load(ctx);
74 }
75 }
76
77 fn selected(&self) -> Option<&DirEntry> {
78 self.list
79 .selected(self.entries.len())
80 .map(|i| &self.entries[i])
81 }
82
83 /// Marked entries, or the selected one, excluding playlists.
84 fn selection(&self) -> Vec<String> {
85 let entries: Vec<&DirEntry> = if self.marks.is_empty() {
86 self.selected().into_iter().collect()
87 } else {
88 let mut marks: Vec<_> = self.marks.iter().copied().collect();
89 marks.sort_unstable();
90 marks.iter().filter_map(|&i| self.entries.get(i)).collect()
91 };
92 entries
93 .into_iter()
94 .filter(|e| !matches!(e, DirEntry::Playlist(_)))
95 .map(|e| e.path().to_owned())
96 .collect()
97 }
98
99 fn activate(&mut self, ctx: &mut Ctx) {
100 let Some(entry) = self.selected().cloned() else {
101 return;
102 };
103 match entry {
104 DirEntry::Directory(path) => self.open(path, ctx),
105 DirEntry::Song(song) => match ctx.mpd.queued_id(&song.file) {
106 Some(id) => ctx.client.exec(commands::play_id(id)),
107 None => ctx.client.request(Request::AddAndPlay(vec![song.file])),
108 },
109 DirEntry::Playlist(name) => {
110 ctx.client.request(Request::LoadAndPlay {
111 name: name.clone(),
112 offset: 0,
113 });
114 ctx.notify(format!("Loaded playlist “{name}”"));
115 }
116 }
117 }
118
119 fn add_to_queue(&mut self, ctx: &mut Ctx) {
120 if !self.marks.is_empty() {
121 let uris = self.selection();
122 let count = uris.len();
123 ctx.client.exec_all(Selection::Uris(uris).add_to_queue());
124 ctx.notify(format!("Added {count} item(s) to the queue"));
125 self.marks.clear();
126 return;
127 }
128 let Some(entry) = self.selected().cloned() else {
129 return;
130 };
131 match entry {
132 DirEntry::Directory(ref path) => {
133 ctx.client.exec(commands::add(path));
134 ctx.notify(format!("Added “{}” to the queue", entry.name()));
135 }
136 // Space toggles a song in and out of the queue.
137 DirEntry::Song(song) => {
138 let ids: Vec<u32> = ctx
139 .mpd
140 .queue
141 .iter()
142 .filter(|q| q.file == song.file)
143 .filter_map(|q| q.id)
144 .collect();
145 if ids.is_empty() {
146 ctx.client.exec(commands::add(&song.file));
147 } else {
148 ctx.client
149 .exec_all(ids.into_iter().map(commands::delete_id).collect());
150 }
151 }
152 DirEntry::Playlist(name) => {
153 ctx.client.exec(commands::load(&name));
154 ctx.notify(format!("Added playlist “{name}” to the queue"));
155 }
156 }
157 self.list.navigate(Action::Down, self.entries.len());
158 }
159}
160
161/// Directories first, then songs in server order, then playlists.
162fn sort_entries(entries: &mut [DirEntry]) {
163 let rank = |e: &DirEntry| match e {
164 DirEntry::Directory(_) => 0,
165 DirEntry::Song(_) => 1,
166 DirEntry::Playlist(_) => 2,
167 };
168 entries.sort_by(|a, b| {
169 rank(a).cmp(&rank(b)).then_with(|| match (a, b) {
170 (DirEntry::Song(_), DirEntry::Song(_)) => std::cmp::Ordering::Equal,
171 _ => a.name().to_lowercase().cmp(&b.name().to_lowercase()),
172 })
173 });
174}
175
176impl Screen for BrowserScreen {
177 fn title(&self, _ctx: &Ctx) -> Line<'static> {
178 Line::from(format!("Browse: /{}", ui::clean(&self.path)))
179 }
180
181 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
182 let theme = ctx.theme();
183 let columns = &ctx.config.ui.song_columns;
184 let span = columns.len() as u16;
185 let entries = &self.entries;
186 let marks = &self.marks;
187 self.list.render(
188 frame,
189 area,
190 Some(Header::Columns(ui::song_header(columns))),
191 &ui::song_widths(columns),
192 entries.len(),
193 true,
194 theme,
195 |i| {
196 // Folders and playlists span the whole row; songs use columns.
197 let row = match &entries[i] {
198 DirEntry::Directory(_) => Row::new([Cell::from(Span::styled(
199 format!("[{}]", ui::clean(entries[i].name())),
200 theme.directory(),
201 ))
202 .column_span(span)]),
203 DirEntry::Song(song) => {
204 let row = ui::song_row(song, columns, ctx.mpd.is_current(song), theme);
205 if ctx.mpd.is_queued(&song.file) {
206 row.style(Style::new().add_modifier(Modifier::BOLD))
207 } else {
208 row
209 }
210 }
211 DirEntry::Playlist(name) => Row::new([Cell::from(Line::from(vec![
212 Span::styled("playlist ", theme.dim()),
213 Span::styled(ui::clean(name), theme.text()),
214 ]))
215 .column_span(span)]),
216 };
217 if marks.contains(&i) {
218 row.style(theme.marked())
219 } else {
220 row
221 }
222 },
223 );
224 if entries.is_empty() && self.loaded {
225 let [_, _, rest] = Layout::vertical([
226 Constraint::Length(1),
227 Constraint::Length(1),
228 Constraint::Fill(1),
229 ])
230 .areas(area);
231 ui::draw_placeholder(frame, rest, &["This folder is empty"], theme);
232 }
233 }
234
235 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
236 if self.list.navigate(action, self.entries.len()) {
237 return true;
238 }
239 match action {
240 Action::Activate | Action::Right => self.activate(ctx),
241 Action::Left => self.back(ctx),
242 Action::AddToQueue => self.add_to_queue(ctx),
243 Action::AddToPlaylist => {
244 let uris = self.selection();
245 if !uris.is_empty() {
246 let popup = AddToPlaylistPopup::new(Selection::Uris(uris), ctx);
247 ctx.popup(popup);
248 self.marks.clear();
249 }
250 }
251 Action::ToggleMark => {
252 if let Some(i) = self.list.selected(self.entries.len()) {
253 if !self.marks.remove(&i) {
254 self.marks.insert(i);
255 }
256 self.list.navigate(Action::Down, self.entries.len());
257 }
258 }
259 Action::ClearMarks => self.marks.clear(),
260 _ => return false,
261 }
262 true
263 }
264
265 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
266 match event {
267 MpdEvent::Reply(Reply::Directory { path, entries }) => {
268 let mut entries = entries.clone();
269 sort_entries(&mut entries);
270 if *path == self.path {
271 self.entries = entries.clone();
272 self.loaded = true;
273 self.apply_restore();
274 }
275 self.cache.insert(path.clone(), entries);
276 }
277 MpdEvent::Changed(subsystems) => {
278 let database = subsystems.contains(&Subsystem::Database);
279 let playlists = subsystems.contains(&Subsystem::StoredPlaylist);
280 if database || playlists {
281 self.cache.clear();
282 if self.loaded && (database || self.path.is_empty()) {
283 // Keep the cursor where it is after the reload.
284 self.restore = self.list.selected(self.entries.len());
285 ctx.client.query(Query::Directory(self.path.clone()));
286 }
287 }
288 }
289 MpdEvent::Connected { .. } => {
290 self.cache.clear();
291 if self.loaded {
292 ctx.client.query(Query::Directory(self.path.clone()));
293 }
294 }
295 _ => {}
296 }
297 }
298
299 fn on_enter(&mut self, ctx: &mut Ctx) {
300 if !self.loaded {
301 self.load(ctx);
302 }
303 }
304
305 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
306 if let Some(Click::Activate(_)) = self.list.mouse(event, self.entries.len()) {
307 self.activate(ctx);
308 }
309 }
310
311 fn search(&mut self, query: &str, _ctx: &Ctx) -> bool {
312 // Match what is displayed: names for folders, labels for songs.
313 let names: Vec<String> = self
314 .entries
315 .iter()
316 .map(|entry| match entry {
317 DirEntry::Song(song) => ui::song_label(song),
318 other => other.name().to_owned(),
319 })
320 .collect();
321 self.list
322 .search(query, names.iter().map(String::as_str), names.len());
323 true
324 }
325}