Krolyxon
Commit a3a311f

rmptuisrcscreenstag_editor.rs

669 lines · 21.5 KB · Rust

1//! Screen 5: the tag editor, laid out like ncmpcpp's.
2//!
3//! `Directories` on the left; `Tag types` in the middle (tag fields, file
4//! name, letter-case helpers, reset and save); `Tags` on the right shows the
5//! selected tag of every song in the highlighted directory. Edits stay pending
6//! until saved.
7
8use std::collections::{BTreeMap, HashSet};
9
10use crossterm::event::MouseEvent;
11use ratatui::{
12 Frame,
13 layout::{Constraint, Position, Rect},
14 style::Modifier,
15 symbols,
16 text::Line,
17 widgets::{Cell, Row},
18};
19
20use super::{Ctx, Effect, Screen, ScreenMessage};
21use crate::{
22 action::Action,
23 mpd::{DirEntry, MpdEvent, Query, Reply, Song, Subsystem},
24 popups::{InputPopup, InputPurpose},
25 tags::{self, TagField, TagWrite},
26 ui::{
27 self,
28 list::{Click, Header, ListView},
29 },
30};
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33enum Pane {
34 #[default]
35 Directories,
36 TagTypes,
37 Tags,
38}
39
40/// The rows of the `Tag types` column.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42enum Item {
43 Field(TagField),
44 Separator,
45 FileName,
46 Capitalize,
47 Lowercase,
48 Reset,
49 Save,
50}
51
52const ITEMS: [Item; 20] = [
53 Item::Field(TagField::Title),
54 Item::Field(TagField::Artist),
55 Item::Field(TagField::AlbumArtist),
56 Item::Field(TagField::Album),
57 Item::Field(TagField::Date),
58 Item::Field(TagField::Track),
59 Item::Field(TagField::Genre),
60 Item::Field(TagField::Composer),
61 Item::Field(TagField::Performer),
62 Item::Field(TagField::Disc),
63 Item::Field(TagField::Comment),
64 Item::Separator,
65 Item::FileName,
66 Item::Separator,
67 Item::Capitalize,
68 Item::Lowercase,
69 Item::Separator,
70 Item::Reset,
71 Item::Save,
72 Item::Separator,
73];
74
75impl Item {
76 fn label(self) -> &'static str {
77 match self {
78 Self::Field(field) => field.label(),
79 Self::Separator => "",
80 Self::FileName => "Filename",
81 Self::Capitalize => "Capitalize First Letters",
82 Self::Lowercase => "lower all letters",
83 Self::Reset => "Reset",
84 Self::Save => "Save",
85 }
86 }
87}
88
89/// Pending changes of one song.
90#[derive(Clone, Debug, Default)]
91struct Changes {
92 tags: BTreeMap<TagField, String>,
93 rename: Option<String>,
94}
95
96impl Changes {
97 fn is_empty(&self) -> bool {
98 self.tags.is_empty() && self.rename.is_none()
99 }
100}
101
102/// What the open input popup edits.
103#[derive(Clone, Debug)]
104enum Editing {
105 /// A tag of these files.
106 Field(Vec<String>),
107 FileName(String),
108}
109
110#[derive(Debug, Default)]
111pub struct TagEditorScreen {
112 focus: Pane,
113 /// The directory whose subdirectories are listed.
114 path: String,
115 stack: Vec<(String, usize)>,
116 restore: Option<usize>,
117 loaded: bool,
118 folders: Vec<String>,
119 /// The directory whose songs are shown in `Tags`.
120 shown: Option<String>,
121 songs: Vec<Song>,
122 dirs: ListView,
123 items: ListView,
124 tags: ListView,
125 marks: HashSet<usize>,
126 /// Pending changes by file.
127 pending: BTreeMap<String, Changes>,
128 editing: Option<Editing>,
129}
130
131impl TagEditorScreen {
132 fn has_parent(&self) -> bool {
133 !self.path.is_empty()
134 }
135
136 /// Rows of the directory column: `.`, `..` (unless at the root), folders.
137 fn dir_rows(&self) -> usize {
138 1 + usize::from(self.has_parent()) + self.folders.len()
139 }
140
141 /// The directory a row of the directory column refers to.
142 fn dir_at(&self, index: usize) -> Option<String> {
143 let parent = usize::from(self.has_parent());
144 match index {
145 0 => Some(self.path.clone()),
146 1 if parent == 1 => Some(
147 self.path
148 .rsplit_once('/')
149 .map_or(String::new(), |(parent, _)| parent.to_owned()),
150 ),
151 _ => self.folders.get(index - 1 - parent).cloned(),
152 }
153 }
154
155 fn load(&mut self, ctx: &Ctx) {
156 self.loaded = false;
157 ctx.client.query(Query::Directory(self.path.clone()));
158 }
159
160 /// Shows the songs of the highlighted directory.
161 fn sync_songs(&mut self, ctx: &Ctx) {
162 let target = self
163 .dirs
164 .selected(self.dir_rows())
165 .and_then(|i| self.dir_at(i));
166 if target != self.shown {
167 self.shown.clone_from(&target);
168 self.songs.clear();
169 self.tags.reset();
170 self.marks.clear();
171 if let Some(dir) = target {
172 ctx.client.query(Query::Directory(dir));
173 }
174 }
175 }
176
177 fn enter_directory(&mut self, ctx: &Ctx) {
178 let Some(index) = self.dirs.selected(self.dir_rows()) else {
179 return;
180 };
181 match index {
182 0 => {}
183 1 if self.has_parent() => self.back(ctx),
184 _ => {
185 let Some(dir) = self.dir_at(index) else {
186 return;
187 };
188 self.stack
189 .push((std::mem::replace(&mut self.path, dir), index));
190 self.dirs.reset();
191 self.folders.clear();
192 self.load(ctx);
193 self.sync_songs(ctx);
194 }
195 }
196 }
197
198 fn back(&mut self, ctx: &Ctx) {
199 if let Some((parent, index)) = self.stack.pop() {
200 self.path = parent;
201 self.dirs.reset();
202 self.folders.clear();
203 self.restore = Some(index);
204 self.load(ctx);
205 }
206 }
207
208 fn selected_item(&self) -> Item {
209 ITEMS[self.items.selected(ITEMS.len()).unwrap_or(0)]
210 }
211
212 /// Moves within `Tag types`, skipping separators.
213 fn navigate_items(&mut self, action: Action) -> bool {
214 let before = self.items.selected(ITEMS.len()).unwrap_or(0);
215 if !self.items.navigate(action, ITEMS.len()) {
216 return false;
217 }
218 let mut index = self.items.selected(ITEMS.len()).unwrap_or(0);
219 while ITEMS[index] == Item::Separator {
220 let up = index < before || index == ITEMS.len() - 1;
221 index = match (up, index) {
222 (true, 0) => break,
223 (true, i) => i - 1,
224 (false, i) => i + 1,
225 };
226 self.items.select(index, ITEMS.len());
227 }
228 true
229 }
230
231 /// The songs an action applies to: marked ones, or all shown songs.
232 fn targets(&self) -> Vec<usize> {
233 if self.marks.is_empty() {
234 (0..self.songs.len()).collect()
235 } else {
236 let mut marks: Vec<_> = self.marks.iter().copied().collect();
237 marks.sort_unstable();
238 marks
239 }
240 }
241
242 /// The (possibly pending) value of `field` for a song.
243 fn value<'a>(&'a self, song: &'a Song, field: TagField) -> &'a str {
244 self.pending
245 .get(&song.file)
246 .and_then(|changes| changes.tags.get(&field))
247 .map(String::as_str)
248 .unwrap_or_else(|| song.tag(field.mpd_tag()).unwrap_or_default())
249 }
250
251 fn file_name<'a>(&'a self, song: &'a Song) -> &'a str {
252 self.pending
253 .get(&song.file)
254 .and_then(|changes| changes.rename.as_deref())
255 .unwrap_or_else(|| song.file_name())
256 }
257
258 fn set_value(&mut self, index: usize, field: TagField, value: String) {
259 let Some(song) = self.songs.get(index) else {
260 return;
261 };
262 let original = song.tag(field.mpd_tag()).unwrap_or_default();
263 let changes = self.pending.entry(song.file.clone()).or_default();
264 if value == original {
265 changes.tags.remove(&field);
266 } else {
267 changes.tags.insert(field, value);
268 }
269 if changes.is_empty() {
270 self.pending.remove(&song.file);
271 }
272 }
273
274 fn set_file_name(&mut self, file: &str, name: String) {
275 let Some(song) = self.songs.iter().find(|s| s.file == file) else {
276 return;
277 };
278 let changes = self.pending.entry(song.file.clone()).or_default();
279 changes.rename = (name != song.file_name()).then_some(name);
280 if changes.is_empty() {
281 self.pending.remove(file);
282 }
283 }
284
285 fn edit_field_for(&mut self, field: TagField, indices: Vec<usize>, ctx: &mut Ctx) {
286 if indices.is_empty() {
287 return;
288 }
289 let mut values = indices.iter().map(|&i| self.value(&self.songs[i], field));
290 let first = values.next().unwrap_or_default();
291 let initial = if values.all(|v| v == first) {
292 first.to_owned()
293 } else {
294 String::new()
295 };
296 let title = match indices.len() {
297 1 => field.label().to_owned(),
298 n => format!("{} ({n} songs)", field.label()),
299 };
300 self.editing = Some(Editing::Field(
301 indices
302 .iter()
303 .map(|&i| self.songs[i].file.clone())
304 .collect(),
305 ));
306 ctx.popup(InputPopup::new(
307 title,
308 &initial,
309 InputPurpose::TagValue(field),
310 ));
311 }
312
313 fn change_case(&mut self, change: fn(&str) -> String) {
314 for index in self.targets() {
315 for field in TagField::ALL.into_iter().filter(|f| f.is_text()) {
316 let current = self.value(&self.songs[index], field).to_owned();
317 let changed = change(&current);
318 if changed != current {
319 self.set_value(index, field, changed);
320 }
321 }
322 }
323 }
324
325 fn activate_item(&mut self, ctx: &mut Ctx) {
326 match self.selected_item() {
327 Item::Field(field) => self.edit_field_for(field, self.targets(), ctx),
328 Item::FileName => self.focus = Pane::Tags,
329 Item::Capitalize => self.change_case(tags::capitalize_words),
330 Item::Lowercase => self.change_case(str::to_lowercase),
331 Item::Reset => {
332 self.pending.clear();
333 ctx.notify("Discarded pending tag changes");
334 }
335 Item::Save => self.save(ctx),
336 Item::Separator => {}
337 }
338 }
339
340 fn activate_song(&mut self, ctx: &mut Ctx) {
341 let Some(index) = self.tags.selected(self.songs.len()) else {
342 return;
343 };
344 match self.selected_item() {
345 Item::FileName => {
346 let song = &self.songs[index];
347 let current = self.file_name(song).to_owned();
348 self.editing = Some(Editing::FileName(song.file.clone()));
349 ctx.popup(InputPopup::new(
350 "Filename",
351 &current,
352 InputPurpose::FileName,
353 ));
354 }
355 Item::Field(field) => self.edit_field_for(field, vec![index], ctx),
356 _ => {}
357 }
358 }
359
360 fn save(&mut self, ctx: &mut Ctx) {
361 if self.pending.is_empty() {
362 ctx.notify("No pending tag changes");
363 return;
364 }
365 let Some(music_dir) = ctx.mpd.music_dir.clone() else {
366 ctx.error("Set mpd.music_directory in the config to write tags");
367 return;
368 };
369 let writes = std::mem::take(&mut self.pending)
370 .into_iter()
371 .map(|(uri, changes)| TagWrite {
372 path: music_dir.join(&uri),
373 uri,
374 changes: changes.tags,
375 rename: changes.rename,
376 })
377 .collect();
378 ctx.effects.push(Effect::WriteTags(writes));
379 }
380}
381
382impl Screen for TagEditorScreen {
383 fn title(&self, _ctx: &Ctx) -> Line<'static> {
384 if self.pending.is_empty() {
385 Line::from("Tag editor")
386 } else {
387 Line::from(format!("Tag editor ({} unsaved)", self.pending.len()))
388 }
389 }
390
391 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
392 let theme = ctx.theme();
393 if ctx.mpd.music_dir.is_none() {
394 ui::draw_placeholder(
395 frame,
396 area,
397 &[
398 "The tag editor needs access to the music files.",
399 "",
400 "Set `music_directory` in the [mpd] section of the rmptui config,",
401 "or in the mpd.conf of a local MPD.",
402 ],
403 theme,
404 );
405 return;
406 }
407 let [dirs_area, items_area, tags_area] = <[Rect; 3]>::try_from(ui::split_columns(
408 frame,
409 area,
410 &[
411 Constraint::Fill(1),
412 Constraint::Length(25),
413 Constraint::Fill(1),
414 ],
415 theme,
416 ))
417 .expect("three columns");
418
419 let parent = self.has_parent();
420 let folders = &self.folders;
421 self.dirs.render(
422 frame,
423 dirs_area,
424 Some(Header::Title(Line::from("Directories"))),
425 &[Constraint::Fill(1)],
426 self.dir_rows(),
427 self.focus == Pane::Directories,
428 theme,
429 |i| {
430 let name = match i {
431 0 => ".".to_owned(),
432 1 if parent => "..".to_owned(),
433 _ => {
434 let path = &folders[i - 1 - usize::from(parent)];
435 ui::clean(path.rsplit('/').next().unwrap_or(path))
436 }
437 };
438 Row::new([Cell::from(name)]).style(theme.directory())
439 },
440 );
441
442 let width = usize::from(items_area.width);
443 self.items.render(
444 frame,
445 items_area,
446 Some(Header::Title(Line::from("Tag types"))),
447 &[Constraint::Fill(1)],
448 ITEMS.len(),
449 self.focus == Pane::TagTypes,
450 theme,
451 |i| match ITEMS[i] {
452 Item::Separator => Row::new([Cell::from(symbols::line::HORIZONTAL.repeat(width))])
453 .style(theme.border()),
454 item => Row::new([Cell::from(item.label())]),
455 },
456 );
457
458 let item = self.selected_item();
459 let rows: Vec<(String, bool)> = self
460 .songs
461 .iter()
462 .map(|song| {
463 let text = match item {
464 Item::Field(field) => self.value(song, field).to_owned(),
465 _ => self.file_name(song).to_owned(),
466 };
467 (text, self.pending.contains_key(&song.file))
468 })
469 .collect();
470 let marks = &self.marks;
471 self.tags.render(
472 frame,
473 tags_area,
474 Some(Header::Title(Line::from("Tags"))),
475 &[Constraint::Fill(1)],
476 rows.len(),
477 self.focus == Pane::Tags,
478 theme,
479 |i| {
480 let (text, changed) = &rows[i];
481 let mut style = theme.text();
482 if *changed {
483 style = style.add_modifier(Modifier::BOLD);
484 }
485 if marks.contains(&i) {
486 style = style.patch(theme.marked());
487 }
488 Row::new([Cell::from(ui::clean(text))]).style(style)
489 },
490 );
491 }
492
493 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
494 match action {
495 Action::SaveTags => {
496 self.save(ctx);
497 return true;
498 }
499 Action::RevertTags => {
500 self.pending.clear();
501 ctx.notify("Discarded pending tag changes");
502 return true;
503 }
504 _ => {}
505 }
506 match self.focus {
507 Pane::Directories => {
508 if self.dirs.navigate(action, self.dir_rows()) {
509 self.sync_songs(ctx);
510 return true;
511 }
512 match action {
513 Action::Activate => self.enter_directory(ctx),
514 Action::Left => {
515 self.back(ctx);
516 self.sync_songs(ctx);
517 }
518 Action::Right => self.focus = Pane::TagTypes,
519 _ => return false,
520 }
521 }
522 Pane::TagTypes => {
523 if self.navigate_items(action) {
524 return true;
525 }
526 match action {
527 Action::Left => self.focus = Pane::Directories,
528 Action::Right if !self.songs.is_empty() => self.focus = Pane::Tags,
529 Action::Activate => self.activate_item(ctx),
530 _ => return false,
531 }
532 }
533 Pane::Tags => {
534 if self.tags.navigate(action, self.songs.len()) {
535 return true;
536 }
537 match action {
538 Action::Left => self.focus = Pane::TagTypes,
539 Action::Activate | Action::Right => self.activate_song(ctx),
540 Action::ToggleMark | Action::AddToQueue => {
541 if let Some(i) = self.tags.selected(self.songs.len()) {
542 if !self.marks.remove(&i) {
543 self.marks.insert(i);
544 }
545 self.tags.navigate(Action::Down, self.songs.len());
546 }
547 }
548 Action::ClearMarks => self.marks.clear(),
549 _ => return false,
550 }
551 }
552 }
553 true
554 }
555
556 fn on_message(&mut self, message: ScreenMessage, _ctx: &mut Ctx) {
557 match (message, self.editing.take()) {
558 (ScreenMessage::TagValue { field, value }, Some(Editing::Field(files))) => {
559 for file in files {
560 if let Some(index) = self.songs.iter().position(|s| s.file == file) {
561 self.set_value(index, field, value.clone());
562 }
563 }
564 }
565 (ScreenMessage::FileName(name), Some(Editing::FileName(file))) => {
566 self.set_file_name(&file, name);
567 }
568 _ => {}
569 }
570 }
571
572 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
573 match event {
574 MpdEvent::Reply(Reply::Directory { path, entries }) => {
575 if *path == self.path {
576 let mut folders: Vec<String> = entries
577 .iter()
578 .filter_map(|e| match e {
579 DirEntry::Directory(dir) => Some(dir.clone()),
580 _ => None,
581 })
582 .collect();
583 folders.sort_by_cached_key(|f| f.to_lowercase());
584 self.folders = folders;
585 self.loaded = true;
586 if let Some(index) = self.restore.take() {
587 self.dirs.select(index, self.dir_rows());
588 }
589 self.sync_songs(ctx);
590 }
591 if Some(path) == self.shown.as_ref() {
592 self.songs = entries
593 .iter()
594 .filter_map(|e| match e {
595 DirEntry::Song(song) => Some(song.clone()),
596 _ => None,
597 })
598 .collect();
599 }
600 }
601 MpdEvent::Changed(subsystems)
602 if subsystems.contains(&Subsystem::Database) && self.loaded =>
603 {
604 // Show freshly written tags and renamed files.
605 ctx.client.query(Query::Directory(self.path.clone()));
606 if let Some(shown) = self.shown.as_ref().filter(|s| **s != self.path) {
607 ctx.client.query(Query::Directory(shown.clone()));
608 }
609 }
610 _ => {}
611 }
612 }
613
614 fn on_enter(&mut self, ctx: &mut Ctx) {
615 if !self.loaded {
616 self.load(ctx);
617 self.shown = None;
618 self.sync_songs(ctx);
619 }
620 }
621
622 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
623 let position = Position::new(event.column, event.row);
624 if self.dirs.contains(position) {
625 self.focus = Pane::Directories;
626 let click = self.dirs.mouse(event, self.dir_rows());
627 self.sync_songs(ctx);
628 if let Some(Click::Activate(_)) = click {
629 self.enter_directory(ctx);
630 }
631 } else if self.items.contains(position) {
632 self.focus = Pane::TagTypes;
633 if let Some(Click::Activate(_)) = self.items.mouse(event, ITEMS.len()) {
634 self.activate_item(ctx);
635 }
636 } else if self.tags.contains(position) {
637 self.focus = Pane::Tags;
638 if let Some(Click::Activate(_)) = self.tags.mouse(event, self.songs.len()) {
639 self.activate_song(ctx);
640 }
641 }
642 }
643
644 fn search(&mut self, query: &str, ctx: &Ctx) -> bool {
645 match self.focus {
646 Pane::Directories => {
647 let mut names = vec![".".to_owned()];
648 if self.has_parent() {
649 names.push("..".to_owned());
650 }
651 names.extend(
652 self.folders
653 .iter()
654 .map(|f| f.rsplit('/').next().unwrap_or(f).to_owned()),
655 );
656 self.dirs
657 .search(query, names.iter().map(String::as_str), names.len());
658 self.sync_songs(ctx);
659 }
660 Pane::Tags => {
661 let len = self.songs.len();
662 self.tags
663 .search(query, self.songs.iter().map(Song::file_name), len);
664 }
665 Pane::TagTypes => return false,
666 }
667 true
668 }
669}