Krolyxon
Commit 96dd350

rmptuisrcscreensbrowser.rs

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