Krolyxon
Commit cf9c69a

rmptuisrcscreenstag_editor.rs

469 lines · 15.5 KB · Rust

1//! Screen 5: editing tags of local files.
2//!
3//! Folders on the left, their songs in the middle, the tags of the selected
4//! (or all marked) songs on the right. Edits stay pending until saved.
5
6use std::collections::{BTreeMap, HashMap, HashSet};
7
8use crossterm::event::MouseEvent;
9use ratatui::{
10 Frame,
11 layout::{Constraint, Layout, Position, Rect},
12 text::{Line, Span},
13 widgets::{Cell, Row},
14};
15
16use super::{Ctx, Effect, Screen, ScreenMessage};
17use crate::{
18 action::Action,
19 mpd::{DirEntry, MpdEvent, Query, Reply, Song, Subsystem},
20 popups::{InputPopup, InputPurpose},
21 tags::{TagField, TagWrite},
22 ui::{
23 self,
24 list::{Click, ListView},
25 },
26};
27
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29enum Pane {
30 #[default]
31 Folders,
32 Songs,
33 Fields,
34}
35
36#[derive(Debug, Default)]
37pub struct TagEditorScreen {
38 focus: Pane,
39 path: String,
40 stack: Vec<(String, usize)>,
41 restore: Option<usize>,
42 loaded: bool,
43 folders: Vec<String>,
44 songs: Vec<Song>,
45 folder_list: ListView,
46 song_list: ListView,
47 field_list: ListView,
48 /// Marked songs by index.
49 marks: HashSet<usize>,
50 /// Pending edits by file.
51 pending: HashMap<String, BTreeMap<TagField, String>>,
52}
53
54impl TagEditorScreen {
55 fn has_parent(&self) -> bool {
56 !self.path.is_empty()
57 }
58
59 /// Rows of the folder pane: `..` (if not at the root), then folders.
60 fn folder_rows(&self) -> usize {
61 self.folders.len() + usize::from(self.has_parent())
62 }
63
64 fn load(&mut self, ctx: &Ctx) {
65 self.loaded = false;
66 ctx.client.query(Query::Directory(self.path.clone()));
67 }
68
69 fn open_selected_folder(&mut self, ctx: &Ctx) {
70 let Some(index) = self.folder_list.selected(self.folder_rows()) else {
71 return;
72 };
73 if self.has_parent() && index == 0 {
74 self.back(ctx);
75 return;
76 }
77 let folder = self.folders[index - usize::from(self.has_parent())].clone();
78 self.stack
79 .push((std::mem::replace(&mut self.path, folder), index));
80 self.reset_lists();
81 self.load(ctx);
82 }
83
84 fn back(&mut self, ctx: &Ctx) {
85 if let Some((parent, index)) = self.stack.pop() {
86 self.path = parent;
87 self.reset_lists();
88 self.restore = Some(index);
89 self.load(ctx);
90 }
91 }
92
93 fn reset_lists(&mut self) {
94 self.folder_list.reset();
95 self.song_list.reset();
96 self.marks.clear();
97 }
98
99 /// Indices of the songs an edit applies to: marked ones, or the selected.
100 fn targets(&self) -> Vec<usize> {
101 if self.marks.is_empty() {
102 self.song_list
103 .selected(self.songs.len())
104 .into_iter()
105 .collect()
106 } else {
107 let mut marks: Vec<_> = self.marks.iter().copied().collect();
108 marks.sort_unstable();
109 marks
110 }
111 }
112
113 /// The (possibly pending) value of `field` for a song.
114 fn value<'a>(&'a self, song: &'a Song, field: TagField) -> &'a str {
115 self.pending
116 .get(&song.file)
117 .and_then(|changes| changes.get(&field))
118 .map(String::as_str)
119 .unwrap_or_else(|| song.tag(field.mpd_tag()).unwrap_or_default())
120 }
121
122 /// The common value of `field` across the targets, or `None` if they differ.
123 fn shared_value(&self, field: TagField) -> Option<String> {
124 let mut values = self
125 .targets()
126 .into_iter()
127 .map(|i| self.value(&self.songs[i], field));
128 let first = values.next()?;
129 values.all(|v| v == first).then(|| first.to_owned())
130 }
131
132 fn edit_field(&mut self, ctx: &mut Ctx) {
133 if self.targets().is_empty() {
134 return;
135 }
136 let Some(index) = self.field_list.selected(TagField::ALL.len()) else {
137 return;
138 };
139 let field = TagField::ALL[index];
140 let count = self.targets().len();
141 let title = if count > 1 {
142 format!("{} ({count} songs)", field.label())
143 } else {
144 field.label().to_owned()
145 };
146 let initial = self.shared_value(field).unwrap_or_default();
147 ctx.popup(InputPopup::new(
148 title,
149 &initial,
150 InputPurpose::TagValue(field),
151 ));
152 }
153
154 fn save(&mut self, ctx: &mut Ctx) {
155 if self.pending.is_empty() {
156 ctx.notify("No pending tag changes");
157 return;
158 }
159 let Some(music_dir) = ctx.mpd.music_dir.clone() else {
160 ctx.error("Set mpd.music_directory in the config to write tags");
161 return;
162 };
163 let writes = self
164 .pending
165 .drain()
166 .map(|(uri, changes)| TagWrite {
167 path: music_dir.join(&uri),
168 uri,
169 changes,
170 })
171 .collect();
172 ctx.effects.push(Effect::WriteTags(writes));
173 }
174}
175
176impl Screen for TagEditorScreen {
177 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
178 let theme = ctx.theme();
179 if ctx.mpd.music_dir.is_none() {
180 let block = ui::pane(" Tag editor ", true, theme);
181 let inner = block.inner(area);
182 frame.render_widget(block, area);
183 ui::draw_placeholder(
184 frame,
185 inner,
186 &[
187 "The tag editor needs access to the music files.",
188 "",
189 "Set `music_directory` in the [mpd] section of the config,",
190 "or connect to MPD through its local Unix socket.",
191 ],
192 theme,
193 );
194 return;
195 }
196 let [folders_area, songs_area, fields_area] = Layout::horizontal([
197 Constraint::Percentage(25),
198 Constraint::Percentage(40),
199 Constraint::Percentage(35),
200 ])
201 .areas(area);
202
203 let parent = self.has_parent();
204 let folders = &self.folders;
205 let title = Line::from(vec![
206 Span::raw(" Folders "),
207 Span::styled(format!("/{} ", self.path), theme.dim()),
208 ]);
209 self.folder_list.render(
210 frame,
211 folders_area,
212 ui::pane(title, self.focus == Pane::Folders, theme),
213 None,
214 &[Constraint::Fill(1)],
215 folders.len() + usize::from(parent),
216 self.focus == Pane::Folders,
217 theme,
218 |i| {
219 let name = if parent && i == 0 {
220 "..".to_owned()
221 } else {
222 let path = &folders[i - usize::from(parent)];
223 format!("▸ {}/", path.rsplit('/').next().unwrap_or(path))
224 };
225 Row::new([Cell::from(name)]).style(theme.directory())
226 },
227 );
228
229 let songs = &self.songs;
230 let pending = &self.pending;
231 let marks = &self.marks;
232 let title = if pending.is_empty() {
233 Line::from(" Songs ")
234 } else {
235 Line::from(vec![
236 Span::raw(" Songs "),
237 Span::styled(format!("{} unsaved ", pending.len()), theme.marked()),
238 ])
239 };
240 let block = ui::pane(title, self.focus == Pane::Songs, theme);
241 if songs.is_empty() {
242 let inner = block.inner(songs_area);
243 frame.render_widget(block, songs_area);
244 let text = if self.loaded {
245 "No songs in this folder"
246 } else {
247 "Loading…"
248 };
249 ui::draw_placeholder(frame, inner, &[text], theme);
250 } else {
251 self.song_list.render(
252 frame,
253 songs_area,
254 block,
255 None,
256 &[Constraint::Length(1), Constraint::Fill(1)],
257 songs.len(),
258 self.focus == Pane::Songs,
259 theme,
260 |i| {
261 let song = &songs[i];
262 let changed = if pending.contains_key(&song.file) {
263 "*"
264 } else {
265 " "
266 };
267 let style = if marks.contains(&i) {
268 theme.marked()
269 } else {
270 theme.text()
271 };
272 Row::new([
273 Cell::from(Span::styled(changed, theme.marked())),
274 Cell::from(song.file_name().to_owned()),
275 ])
276 .style(style)
277 },
278 );
279 }
280
281 let targets = self.targets();
282 let title = match targets.len() {
283 0 => " Tags ".to_owned(),
284 1 => " Tags ".to_owned(),
285 n => format!(" Tags · {n} songs "),
286 };
287 let values: Vec<(TagField, Option<String>)> = TagField::ALL
288 .iter()
289 .map(|&f| (f, self.shared_value(f)))
290 .collect();
291 let has_targets = !targets.is_empty();
292 self.field_list.render(
293 frame,
294 fields_area,
295 ui::pane(title, self.focus == Pane::Fields, theme),
296 None,
297 &[Constraint::Length(13), Constraint::Fill(1)],
298 values.len(),
299 self.focus == Pane::Fields,
300 theme,
301 |i| {
302 let (field, value) = &values[i];
303 let value = match value {
304 _ if !has_targets => Span::raw(""),
305 Some(v) => Span::styled(v.clone(), theme.text()),
306 None => Span::styled("<various>", theme.dim()),
307 };
308 Row::new([
309 Cell::from(Span::styled(field.label(), theme.dim())),
310 Cell::from(value),
311 ])
312 },
313 );
314 }
315
316 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
317 match action {
318 Action::SaveTags => {
319 self.save(ctx);
320 return true;
321 }
322 Action::RevertTags => {
323 self.pending.clear();
324 ctx.notify("Discarded pending tag changes");
325 return true;
326 }
327 _ => {}
328 }
329 match self.focus {
330 Pane::Folders => {
331 if self.folder_list.navigate(action, self.folder_rows()) {
332 return true;
333 }
334 match action {
335 Action::Activate => self.open_selected_folder(ctx),
336 Action::Left => self.back(ctx),
337 Action::Right if !self.songs.is_empty() => self.focus = Pane::Songs,
338 _ => return false,
339 }
340 }
341 Pane::Songs => {
342 if self.song_list.navigate(action, self.songs.len()) {
343 return true;
344 }
345 match action {
346 Action::Left => self.focus = Pane::Folders,
347 Action::Right | Action::Activate => self.focus = Pane::Fields,
348 Action::ToggleMark | Action::AddToQueue => {
349 if let Some(i) = self.song_list.selected(self.songs.len()) {
350 if !self.marks.remove(&i) {
351 self.marks.insert(i);
352 }
353 self.song_list.navigate(Action::Down, self.songs.len());
354 }
355 }
356 Action::ClearMarks => self.marks.clear(),
357 _ => return false,
358 }
359 }
360 Pane::Fields => {
361 if self.field_list.navigate(action, TagField::ALL.len()) {
362 return true;
363 }
364 match action {
365 Action::Left => self.focus = Pane::Songs,
366 Action::Activate | Action::Right => self.edit_field(ctx),
367 _ => return false,
368 }
369 }
370 }
371 true
372 }
373
374 fn on_message(&mut self, message: ScreenMessage, _ctx: &mut Ctx) {
375 let ScreenMessage::TagValue { field, value } = message;
376 for index in self.targets() {
377 let song = &self.songs[index];
378 let original = song.tag(field.mpd_tag()).unwrap_or_default();
379 let changes = self.pending.entry(song.file.clone()).or_default();
380 if value == original {
381 changes.remove(&field);
382 } else {
383 changes.insert(field, value.clone());
384 }
385 if changes.is_empty() {
386 self.pending.remove(&song.file);
387 }
388 }
389 }
390
391 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
392 match event {
393 MpdEvent::Reply(Reply::Directory { path, entries }) if *path == self.path => {
394 let mut folders = Vec::new();
395 let mut songs = Vec::new();
396 for entry in entries {
397 match entry {
398 DirEntry::Directory(dir) => folders.push(dir.clone()),
399 DirEntry::Song(song) => songs.push(song.clone()),
400 DirEntry::Playlist(_) => {}
401 }
402 }
403 folders.sort_by_cached_key(|f| f.to_lowercase());
404 self.folders = folders;
405 self.songs = songs;
406 self.loaded = true;
407 if let Some(index) = self.restore.take() {
408 self.folder_list.select(index, self.folder_rows());
409 }
410 }
411 MpdEvent::Changed(subsystems)
412 if subsystems.contains(&Subsystem::Database) && self.loaded =>
413 {
414 // Show freshly written tags.
415 ctx.client.query(Query::Directory(self.path.clone()));
416 }
417 _ => {}
418 }
419 }
420
421 fn on_enter(&mut self, ctx: &mut Ctx) {
422 if !self.loaded {
423 self.load(ctx);
424 }
425 }
426
427 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
428 let position = Position::new(event.column, event.row);
429 if self.folder_list.contains(position) {
430 self.focus = Pane::Folders;
431 if let Some(Click::Activate(_)) = self.folder_list.mouse(event, self.folder_rows()) {
432 self.open_selected_folder(ctx);
433 }
434 } else if self.song_list.contains(position) {
435 self.focus = Pane::Songs;
436 self.song_list.mouse(event, self.songs.len());
437 } else if self.field_list.contains(position) {
438 self.focus = Pane::Fields;
439 if let Some(Click::Activate(_)) = self.field_list.mouse(event, TagField::ALL.len()) {
440 self.edit_field(ctx);
441 }
442 }
443 }
444
445 fn search(&mut self, query: &str, _ctx: &Ctx) -> bool {
446 match self.focus {
447 Pane::Folders => {
448 let mut names: Vec<&str> = Vec::new();
449 if self.has_parent() {
450 names.push("..");
451 }
452 names.extend(
453 self.folders
454 .iter()
455 .map(|f| f.rsplit('/').next().unwrap_or(f)),
456 );
457 let len = names.len();
458 self.folder_list.search(query, names.into_iter(), len);
459 }
460 Pane::Songs => {
461 let len = self.songs.len();
462 self.song_list
463 .search(query, self.songs.iter().map(Song::file_name), len);
464 }
465 Pane::Fields => return false,
466 }
467 true
468 }
469}