Krolyxon
Commit 61cb83e

rmptui61cb83e

Adopt ncmpcpp's look, global clear, and mpd.conf music directory

- Replace the boxed layout and tab bar with ncmpcpp's frame: screen title
  and volume on top, column headers over separator lines, columns split by
  vertical lines, reverse-video selection, a thin progress bar and a
  'Playing: "Title" by Artist in Album' status line.
- Browser lists songs in the queue's columns; folders ([name]) and
  playlists span the whole row. The playlist editor uses the same table.
- Rebuild the tag editor like ncmpcpp (Directories / Tag types / Tags) with
  Composer and Performer, file renaming and letter-case helpers.
- Read music_directory from the local mpd.conf when MPD runs on this
  machine, since MPD only reports it over a Unix socket.
- Make clear_queue a global binding (c); ui.confirm_clear_queue opts into
  a confirmation.
- Strip zero-width characters from displayed text to avoid stray glyphs.
Author
krolyxon
Date
2026-10-17 17:24 +0530
Parent
cf9c69a
Commit
61cb83e5cbb4a1a4555a815bbb03c03999a7fc81

+1354 −972

28 files changed

  1. M .github/copilot-instructions.md +1 −1
  2. M CHANGELOG.md +8 −0
  3. M README.md +40 −21
  4. M src/action.rs +2 −2
  5. M src/app.rs +27 −30
  6. M src/config/default.toml +29 −16
  7. M src/config/mod.rs +8 −5
  8. A src/config/mpd_conf.rs +87 −0
  9. M src/config/theme.rs +26 −13
  10. M src/mpd/connection.rs +17 −0
  11. M src/popups/add_to_playlist.rs +3 −4
  12. M src/popups/confirm.rs +2 −2
  13. M src/popups/help.rs +2 −3
  14. M src/popups/input.rs +5 −0
  15. M src/popups/mod.rs +3 −3
  16. M src/screens/browser.rs +40 −49
  17. M src/screens/library.rs +82 −99
  18. M src/screens/lyrics.rs +23 −17
  19. M src/screens/mod.rs +5 −1
  20. M src/screens/playlists.rs +27 −79
  21. M src/screens/queue.rs +36 −82
  22. M src/screens/tag_editor.rs +434 −234
  23. M src/screens/visualizer.rs +6 −9
  24. M src/tags.rs +114 −40
  25. M src/ui/input.rs +1 −1
  26. M src/ui/list.rs +39 −20
  27. M src/ui/mod.rs +250 −241
  28. M src/util.rs +37 −0
M .github/copilot-instructions.md View
@@ -34,7 +34,7 @@
3434 - Every screen sees every `MpdEvent`, so caches stay valid while hidden.
3535 - `popups/` holds modal components (`Popup` trait); the topmost popup gets all
3636 keys. Destructive actions go through `ConfirmPopup`.
37−- `ui/` holds the frame chrome (header, tabs, progress, status line), the
37+- `ui/` holds the ncmpcpp-style frame (title row, progress bar, status line), the
3838 virtualized searchable `ListView`, and the `LineInput` editor.
3939 - `config/`:
4040 - `default.toml` is embedded and is the single source of truth for
M CHANGELOG.md View
@@ -16,6 +16,14 @@ A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc.
1616 columns and lyrics/visualizer settings. The documented default file is the
1717 single source of truth; partial files keep all other defaults. 0.1 config
1818 files are detected and rejected with instructions.
19+- ncmpcpp-style interface: screen title and volume on top, column headers,
20+ folders spanning the whole row, reverse-video selection and a
21+ `Playing: "Title" by Artist in Album` status line under the progress bar.
22+- The tag editor follows ncmpcpp (directories, tag types, tags) and can also
23+ rename files and fix letter case.
24+- The music directory is read from the local `mpd.conf` when MPD runs on the
25+ same machine.
26+- `c` clears the queue from every screen.
1927 - New `--config` and `--print-default-config` options; `RMPTUI_LOG` sets
2028 the log level.
2129
M README.md View
@@ -6,7 +6,7 @@
66 **A fast, keyboard-driven MPD client for your terminal.**
77
88 rmptui is a terminal interface for [Music Player Daemon
9−(MPD)](https://www.musicpd.org/) in the spirit of ncmpcpp and rmpc. Browse your
9+(MPD)](https://www.musicpd.org/) that looks and feels like ncmpcpp. Browse your
1010 library, manage the queue and playlists, edit tags, read synced lyrics and
1111 watch a spectrum visualizer without leaving the terminal.
1212
@@ -16,14 +16,19 @@ watch a spectrum visualizer without leaving the terminal.
1616
1717 | Key | Screen | What it does |
1818 | --- | --- | --- |
19−| `1` | **Queue** | The play queue with configurable columns. Play, remove, reorder, mark, shuffle, clear or save it as a playlist. `L` shows a synced lyrics pane next to it. |
20−| `2` | **Browse** | Your music folders, ncmpcpp style. Stored playlists are listed at the bottom of the root folder. |
21−| `3` | **Playlists** | Stored playlists and their songs: load, play from a song, remove or reorder songs, rename or delete playlists. |
22−| `4` | **Library** | Album artists → albums (with year) → songs. `t` switches between AlbumArtist and Artist. |
23−| `5` | **Tags** | Edit Title, Artist, Album Artist, Album, Track, Disc, Date, Genre and Comment of one or many songs. |
24−| `6` | **Visualizer** | A spectrum analyzer fed by an MPD fifo output. |
19+| `1` | **Playlist** | The play queue as a table (Artist, Track, Title, Album, Time). Play, remove, reorder, mark, shuffle or save it as a playlist. `L` shows a synced lyrics pane next to it. |
20+| `2` | **Browse** | Your music folders. Folders (`[name]`) span the whole row, songs use the same columns as the queue, and stored playlists are listed at the bottom of the root folder. |
21+| `3` | **Playlist editor** | Stored playlists and their songs: load, play from a song, remove or reorder songs, rename or delete playlists. |
22+| `4` | **Media library** | Album artists → albums (with year) → songs. `t` switches between AlbumArtist and Artist. |
23+| `5` | **Tag editor** | Directories, tag types and tags, like ncmpcpp: edit Title, Artist, Album Artist, Album, Date, Track, Genre, Composer, Performer, Disc and Comment of one or many songs, rename files, fix letter case, then save. |
24+| `6` | **Music visualizer** | A spectrum analyzer fed by an MPD fifo output. |
2525 | `7` | **Lyrics** | Synced lyrics that follow playback, or plain lyrics you can scroll. |
2626
27+Every screen shares ncmpcpp's frame: the screen title and volume on top
28+(active modes such as `[rz]` appear on the line below), and at the bottom a
29+progress bar above `Playing: "Title" by Artist in Album (Year)` with the
30+bitrate and elapsed/total time.
31+
2732 Other highlights:
2833
2934 - **Instant controls.** All MPD traffic runs in background tasks over MPD's
@@ -34,7 +39,7 @@ Other highlights:
3439 - **Marks.** `v` marks items; queue removal, add-to-playlist and tag edits
3540 then apply to every marked item.
3641 - **Mouse support.** Click to select, double-click to play/open, scroll with
37− the wheel, click a tab to switch screens and click the progress bar to seek.
42+ the wheel and click the progress bar to seek.
3843 - **TCP or Unix socket**, with password support and automatic reconnection.
3944
4045 ## Requirements
@@ -95,7 +100,7 @@ The main sections:
95100 host = "127.0.0.1" # or a Unix socket path such as "~/.config/mpd/socket"
96101 port = 6600
97102 password = ""
98−music_directory = "" # needed for the tag editor and local .lrc files
103+music_directory = "" # for the tag editor and local .lrc files (see below)
99104
100105 [playback]
101106 seek_seconds = 5
@@ -104,17 +109,21 @@ volume_step = 5
104109 [ui]
105110 startup_screen = "queue"
106111 mouse = true
112+confirm_clear_queue = false
107113 lyrics_pane = false
108−queue_columns = [
109− { field = "artist", width = 25 },
114+song_columns = [ # queue, browser and playlist editor
115+ { field = "artist", width = 34 },
110116 { field = "track", width = 5 },
111− { field = "title", width = 35 },
117+ { field = "title", width = 33 },
112118 { field = "album", width = 30 },
113− { field = "duration", width = 7 },
119+ { field = "duration", width = 5 },
114120 ]
115121
116−[theme]
117−accent = "magenta" # color names, 256-color indices or "#rrggbb"
122+[theme] # color names, 256-color indices or "#rrggbb"
123+text = "reset"
124+dim = "darkgray"
125+selection_fg = "reset" # both "reset": reverse video, like ncmpcpp
126+selection_bg = "reset"
118127 visualizer = ["blue", "cyan", "green", "yellow", "red"]
119128
120129 [lyrics]
@@ -173,10 +182,20 @@ the lyrics of the current song again.
173182
174183 ### Tag editor
175184
176−MPD cannot change tags, so the tag editor writes the files directly. That
177−requires the music files to be on this machine: set `mpd.music_directory`, or
178−connect through MPD's Unix socket so rmptui can ask MPD for it. After saving,
179−rmptui asks MPD to rescan the edited folders.
185+MPD cannot change tags, so the tag editor writes the files directly, which
186+requires the music files to be on this machine. rmptui finds the music
187+directory in this order: `mpd.music_directory` in its config, MPD itself (only
188+answered over a Unix socket), and finally `music_directory` from the local
189+MPD's `mpd.conf` (`$XDG_CONFIG_HOME/mpd/mpd.conf`, `~/.config/mpd/mpd.conf`,
190+`~/.mpdconf`, `~/.mpd/mpd.conf` or `/etc/mpd.conf`) when MPD runs on this
191+machine.
192+
193+Highlight a directory to see its songs in the `Tags` column, which shows the
194+tag selected in `Tag types`. `Enter` on a tag type edits it for all marked
195+songs (or all songs), `Enter` on a song edits it for that song only, and
196+`Filename` renames files. Changes stay pending until you choose `Save` (or
197+press `w`); `Reset` (or `u`) discards them. After saving, rmptui asks MPD to
198+rescan the folder.
180199
181200 ## Key bindings
182201
@@ -201,6 +220,7 @@ Playlists screen and toggles consume elsewhere).
201220 | `+` `=` / `-` | Volume up / down |
202221 | `m` | Toggle mute |
203222 | `r` `z` `y` `R` | Toggle repeat, random, single, consume |
223+| `c` | Clear the queue |
204224 | `U` | Rescan the music database |
205225 | `?` `F1` | Show key bindings |
206226 | `q` `Ctrl+c` | Quit |
@@ -225,14 +245,13 @@ Playlists screen and toggles consume elsewhere).
225245 | --- | --- | --- |
226246 | Queue | `d` `Delete` | Remove the song (or all marked songs) |
227247 | Queue | `J` / `K` | Move the song down / up |
228−| Queue | `c` | Clear the queue (asks first) |
229248 | Queue | `Z` / `S` | Shuffle / save as playlist |
230249 | Queue | `o` | Jump to the playing song |
231250 | Queue | `L` | Toggle the lyrics pane |
232251 | Playlists | `d`, `J` / `K` | Remove / move the selected song |
233252 | Playlists | `R` / `D` | Rename / delete the playlist |
234253 | Library | `t` | Switch between AlbumArtist and Artist |
235−| Tags | `Enter` | Edit the selected tag |
254+| Tags | `Enter` | Edit a tag, rename a file, or run the highlighted action |
236255 | Tags | `w` / `u` | Write / discard pending changes |
237256 | Lyrics | `F5` | Fetch lyrics again |
238257
M src/action.rs View
@@ -210,12 +210,12 @@ impl Action {
210210 | SeekBackward | VolumeUp | VolumeDown | ToggleMute | ToggleRepeat | ToggleRandom
211211 | ToggleSingle | ToggleConsume | UpdateDatabase | NextScreen | PreviousScreen
212212 | ScreenQueue | ScreenBrowser | ScreenPlaylists | ScreenLibrary | ScreenTagEditor
213− | ScreenVisualizer | ScreenLyrics => &[C::Global],
213+ | ScreenVisualizer | ScreenLyrics | ClearQueue => &[C::Global],
214214 Up | Down | Top | Bottom | PageUp | PageDown | HalfPageUp | HalfPageDown | Left
215215 | Right | Activate | ToggleMark | ClearMarks | Search | SearchNext | SearchPrevious
216216 | AddToQueue | AddToPlaylist => &[C::Navigation],
217217 Delete | MoveItemUp | MoveItemDown => &[C::Queue, C::Playlists],
218− ClearQueue | ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue],
218+ ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue],
219219 DeletePlaylist | RenamePlaylist => &[C::Playlists],
220220 ToggleArtistTag => &[C::Library],
221221 SaveTags | RevertTags => &[C::TagEditor],
M src/app.rs View
@@ -13,11 +13,11 @@ use tracing::info;
1313
1414 use crate::{
1515 action::{Action, Context, ScreenId},
16− config::{Config, KeyCombo},
16+ config::{self, Config, KeyCombo},
1717 event::AppEvent,
1818 lyrics::LyricsService,
1919 mpd::{self, MpdEvent, MpdHandle, PlayState, Query, Reply, Subsystem, commands},
20− popups::{HelpPopup, Popup, PopupState},
20+ popups::{ConfirmPopup, HelpPopup, Popup, PopupState},
2121 screens::{Ctx, Effect, Screens},
2222 state::MpdState,
2323 tags,
@@ -46,7 +46,6 @@ pub struct App {
4646 lyrics: LyricsService,
4747 visualizer: Visualizer,
4848 layout: Option<FrameLayout>,
49− tab_hits: Vec<(u16, u16, ScreenId)>,
5049 /// The volume before muting.
5150 muted_volume: Option<u8>,
5251 queue_requested: bool,
@@ -57,12 +56,17 @@ impl App {
5756 pub fn new(config: Config) -> anyhow::Result<Self> {
5857 let (address, password) = config.mpd_connection()?;
5958 info!("connecting to MPD at {address}");
59+ // MPD only reports its music directory over a Unix socket; for a
60+ // local server, its config file is the next best source.
61+ let music_dir = config
62+ .music_directory()
63+ .or_else(|| config::local_music_directory(&address));
6064 let (events_tx, events_rx) = mpsc::unbounded_channel();
6165 let client = mpd::spawn(address, password, events_tx.clone());
6266 let visualizer = Visualizer::spawn(config.visualizer.clone());
6367 let screens = Screens::new(&config, visualizer.subscribe());
6468 let mut mpd = MpdState::default();
65− mpd.music_dir = config.music_directory();
69+ mpd.music_dir = music_dir;
6670 let mut app = Self {
6771 lyrics: LyricsService::new(config.lyrics.clone(), events_tx.clone()),
6872 active: config.ui.startup_screen,
@@ -77,7 +81,6 @@ impl App {
7781 message: None,
7882 visualizer,
7983 layout: None,
80− tab_hits: Vec::new(),
8184 muted_volume: None,
8285 queue_requested: false,
8386 running: true,
@@ -320,6 +323,18 @@ impl App {
320323 Action::ToggleRandom => client.exec(commands::random(!status.random)),
321324 Action::ToggleSingle => client.exec(commands::single(!status.single.is_on())),
322325 Action::ToggleConsume => client.exec(commands::consume(!status.consume.is_on())),
326+ Action::ClearQueue if self.mpd.queue.is_empty() => {}
327+ Action::ClearQueue if self.config.ui.confirm_clear_queue => {
328+ let popup = ConfirmPopup::new(
329+ format!("Remove all {} songs from the queue?", self.mpd.queue.len()),
330+ vec![commands::clear()],
331+ );
332+ self.popups.push(Box::new(popup));
333+ }
334+ Action::ClearQueue => {
335+ client.exec(commands::clear());
336+ self.notify("Cleared the queue".into(), false);
337+ }
323338 Action::UpdateDatabase => {
324339 client.exec(commands::update(None));
325340 self.notify("Updating the database…".into(), false);
@@ -346,16 +361,7 @@ impl App {
346361 };
347362 let position = Position::new(mouse.column, mouse.row);
348363 let left_click = mouse.kind == MouseEventKind::Down(MouseButton::Left);
349− if layout.tabs.contains(position) {
350− if left_click
351− && let Some(&(_, _, screen)) = self
352− .tab_hits
353− .iter()
354− .find(|(start, end, _)| (*start..*end).contains(&mouse.column))
355− {
356− self.switch_to(screen);
357− }
358− } else if layout.progress.contains(position) {
364+ if layout.progress.contains(position) {
359365 if left_click
360366 && self.mpd.status.state != PlayState::Stop
361367 && let Some(target) =
@@ -442,7 +448,7 @@ impl App {
442448 self.mpd.playlists.clone_from(playlists)
443449 }
444450 MpdEvent::Reply(Reply::MusicDirectory(dir)) => {
445− if self.config.music_directory().is_none() {
451+ if self.config.music_directory().is_none() && dir.is_some() {
446452 self.mpd.music_dir.clone_from(dir);
447453 }
448454 }
@@ -496,13 +502,10 @@ impl App {
496502 fn draw(&mut self, frame: &mut Frame) {
497503 let layout = FrameLayout::new(frame.area());
498504 let theme = &self.config.theme;
499− ui::draw_header(frame, layout.header, &self.mpd, theme);
500− self.tab_hits = ui::draw_tabs(frame, layout.tabs, self.active, theme);
501−
502505 let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state);
503− self.screens
504− .get_mut(self.active)
505− .draw(frame, layout.body, &ctx);
506+ let screen = self.screens.get_mut(self.active);
507+ ui::draw_title(frame, &layout, screen.title(&ctx), &self.mpd, theme);
508+ screen.draw(frame, layout.body, &ctx);
506509 ui::draw_progress(frame, layout.progress, &self.mpd, theme);
507510
508511 if self
@@ -513,17 +516,11 @@ impl App {
513516 self.message = None;
514517 }
515518 if let Some(search) = &self.search {
516− search.render(frame, layout.status, " /", theme);
519+ search.render(frame, layout.status, "/", theme);
517520 } else if let Some(message) = &self.message {
518521 ui::draw_message(frame, layout.status, &message.text, message.error, theme);
519522 } else {
520− let help = self
521− .config
522− .keymap
523− .keys_for(Context::Global, Action::Help)
524− .first()
525− .map_or_else(String::new, |key| format!("{key} for help"));
526− ui::draw_hint(frame, layout.status, &help, theme);
523+ ui::draw_now_playing(frame, layout.status, &self.mpd, theme);
527524 }
528525 for popup in &mut self.popups {
529526 popup.draw(frame, frame.area(), &ctx);
M src/config/default.toml View
@@ -29,39 +29,52 @@ volume_step = 5
2929 # Screen shown on startup: queue, browser, playlists, library, tag_editor,
3030 # visualizer or lyrics.
3131 startup_screen = "queue"
32−# Enable mouse support (click to select, wheel to scroll, click the progress
33−# bar to seek, click a tab to switch).
32+# Enable mouse support (click to select, double-click to play/open, wheel to
33+# scroll, click the progress bar to seek).
3434 mouse = true
35+# Ask before clearing the queue.
36+confirm_clear_queue = false
3537 # Show the lyrics pane next to the queue on startup (toggle with `L`).
3638 lyrics_pane = false
3739 # Width of the lyrics pane in percent of the screen.
3840 lyrics_pane_width = 40
3941 # Seconds a status message stays visible.
4042 message_timeout = 4
41−# Queue columns. `field` is one of artist, album_artist, title, album, track,
42−# disc, date, genre, duration or file; `width` is a relative weight.
43−queue_columns = [
44− { field = "artist", width = 25 },
43+# Columns of the song tables (queue, browser, playlist contents). `field` is
44+# one of artist, album_artist, title, album, track, disc, date, genre,
45+# duration or file. `width` is a relative weight, except for track, disc and
46+# duration, where it is a fixed number of characters.
47+song_columns = [
48+ { field = "artist", width = 34 },
4549 { field = "track", width = 5 },
46− { field = "title", width = 35 },
50+ { field = "title", width = 33 },
4751 { field = "album", width = 30 },
48− { field = "duration", width = 7 },
52+ { field = "duration", width = 5 },
4953 ]
5054
5155 [theme]
5256 # Colors are names (black, red, green, yellow, blue, magenta, cyan, gray,
5357 # darkgray, lightred, …, white, reset), 256-color indices ("208") or hex
54−# values ("#c694ff").
55−accent = "magenta"
58+# values ("#c694ff"). "reset" is your terminal's default color.
5659 text = "reset"
5760 dim = "darkgray"
58−selection_fg = "black"
59−selection_bg = "magenta"
60−playing = "green"
61+# Horizontal and vertical separator lines.
62+border = "gray"
63+# The selected row. With both set to "reset", the row is shown in reverse
64+# video like ncmpcpp.
65+selection_fg = "reset"
66+selection_bg = "reset"
67+playing = "reset"
6168 marked = "yellow"
62−directory = "blue"
63−border = "darkgray"
69+directory = "reset"
6470 error = "red"
71+# Song table columns.
72+artist = "reset"
73+track = "darkgray"
74+album = "reset"
75+duration = "reset"
76+# The filled part of the progress bar.
77+progress = "gray"
6578 # Visualizer bar gradient, from the bottom of the screen to the top.
6679 visualizer = ["blue", "cyan", "green", "yellow", "red"]
6780
@@ -123,6 +136,7 @@ toggle_random = ["z"]
123136 toggle_single = ["y"]
124137 toggle_consume = ["R"]
125138 update_database = ["U"]
139+clear_queue = ["c"]
126140 next_screen = ["Tab"]
127141 previous_screen = ["Shift+Tab"]
128142 screen_queue = ["1"]
@@ -157,7 +171,6 @@ add_to_playlist = ["a"]
157171 delete = ["d", "Delete"]
158172 move_item_up = ["K"]
159173 move_item_down = ["J"]
160−clear_queue = ["c"]
161174 shuffle_queue = ["Z"]
162175 save_queue = ["S"]
163176 jump_to_current = ["o"]
M src/config/mod.rs View
@@ -5,6 +5,7 @@
55 //! leave out.
66
77 mod keys;
8+mod mpd_conf;
89 mod theme;
910
1011 use std::{
@@ -19,6 +20,7 @@ use std::{
1920 use serde::Deserialize;
2021
2122 pub use keys::{Bindings, KeyCombo, Keymap};
23+pub use mpd_conf::local_music_directory;
2224 pub use theme::Theme;
2325
2426 use crate::{action::ScreenId, mpd::Address, util::expand_tilde};
@@ -80,7 +82,7 @@ impl ColumnField {
8082 Self::AlbumArtist => "Album Artist",
8183 Self::Title => "Title",
8284 Self::Album => "Album",
83− Self::Track => "#",
85+ Self::Track => "Track",
8486 Self::Disc => "Disc",
8587 Self::Date => "Date",
8688 Self::Genre => "Genre",
@@ -102,10 +104,11 @@ pub struct Column {
102104 pub struct UiConfig {
103105 pub startup_screen: ScreenId,
104106 pub mouse: bool,
107+ pub confirm_clear_queue: bool,
105108 pub lyrics_pane: bool,
106109 pub lyrics_pane_width: u16,
107110 pub message_timeout: u64,
108− pub queue_columns: Vec<Column>,
111+ pub song_columns: Vec<Column>,
109112 }
110113
111114 #[derive(Clone, Debug, Deserialize)]
@@ -246,8 +249,8 @@ impl Config {
246249 "ui.lyrics_pane_width must be between 10 and 90",
247250 )?;
248251 check(
249− !self.ui.queue_columns.is_empty(),
250− "ui.queue_columns cannot be empty",
252+ !self.ui.song_columns.is_empty(),
253+ "ui.song_columns cannot be empty",
251254 )?;
252255 let vis = &self.visualizer;
253256 check(
@@ -447,7 +450,7 @@ mod tests {
447450 "[mpd]\nport = 0",
448451 "[playback]\nvolume_step = 0",
449452 "[mpd]\nhots = \"typo\"",
450− "[theme]\naccent = \"notacolor\"",
453+ "[theme]\ntext = \"notacolor\"",
451454 "[keys.global]\nquit = [\"Hyper+q\"]",
452455 "[keys.global]\nnot_an_action = [\"x\"]",
453456 // `p` is already bound to toggle_pause.
A src/config/mpd_conf.rs View
@@ -0,0 +1,87 @@
1+//! Reading `music_directory` from the local MPD configuration.
2+//!
3+//! MPD only reveals its music directory over a Unix socket, so for a local
4+//! server reached over TCP rmptui reads it from MPD's own config file.
5+
6+use std::{
7+ env, fs,
8+ path::{Path, PathBuf},
9+};
10+
11+use crate::{mpd::Address, util::expand_tilde};
12+
13+/// The music directory of a local MPD, if `address` is local and an MPD
14+/// config file names one.
15+pub fn local_music_directory(address: &Address) -> Option<PathBuf> {
16+ if !address.is_local() {
17+ return None;
18+ }
19+ candidates()
20+ .iter()
21+ .find_map(|path| fs::read_to_string(path).ok())
22+ .and_then(|contents| parse_music_directory(&contents))
23+}
24+
25+/// MPD's config file locations, in MPD's lookup order.
26+fn candidates() -> Vec<PathBuf> {
27+ let mut paths = Vec::new();
28+ if let Some(dir) = env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) {
29+ paths.push(Path::new(&dir).join("mpd/mpd.conf"));
30+ }
31+ paths.extend(
32+ [
33+ "~/.config/mpd/mpd.conf",
34+ "~/.mpdconf",
35+ "~/.mpd/mpd.conf",
36+ "/etc/mpd.conf",
37+ ]
38+ .map(expand_tilde),
39+ );
40+ paths
41+}
42+
43+fn parse_music_directory(contents: &str) -> Option<PathBuf> {
44+ contents.lines().find_map(|line| {
45+ let line = line.trim();
46+ let value = line.strip_prefix("music_directory")?;
47+ if !value.starts_with(char::is_whitespace) {
48+ return None;
49+ }
50+ let value = value.trim();
51+ let value = value
52+ .strip_prefix('"')
53+ .and_then(|v| v.split_once('"'))
54+ .map_or(value, |(inner, _)| inner);
55+ (!value.is_empty()).then(|| expand_tilde(value))
56+ })
57+}
58+
59+#[cfg(test)]
60+mod tests {
61+ use super::*;
62+
63+ #[test]
64+ fn parses_music_directory() {
65+ let conf = "# music_directory \"/commented\"\n\
66+ music_directory_x \"/no\"\n\
67+ music_directory\t\t\"~/music\" # trailing comment\n";
68+ let home = env::var("HOME").unwrap();
69+ assert_eq!(
70+ parse_music_directory(conf),
71+ Some(PathBuf::from(format!("{home}/music")))
72+ );
73+ assert_eq!(
74+ parse_music_directory("music_directory /srv/music"),
75+ Some(PathBuf::from("/srv/music"))
76+ );
77+ assert_eq!(parse_music_directory("port \"6600\""), None);
78+ }
79+
80+ #[test]
81+ fn ignores_remote_servers() {
82+ assert_eq!(
83+ local_music_directory(&Address::Tcp("192.168.1.20:6600".into())),
84+ None
85+ );
86+ }
87+}
M src/config/theme.rs View
@@ -7,13 +7,13 @@ use serde::{Deserialize, Deserializer, de};
77 #[derive(Clone, Debug, Deserialize)]
88 #[serde(deny_unknown_fields)]
99 pub struct Theme {
10− #[serde(deserialize_with = "color")]
11− pub accent: Color,
1210 #[serde(deserialize_with = "color")]
1311 pub text: Color,
1412 #[serde(deserialize_with = "color")]
1513 pub dim: Color,
1614 #[serde(deserialize_with = "color")]
15+ pub border: Color,
16+ #[serde(deserialize_with = "color")]
1717 pub selection_fg: Color,
1818 #[serde(deserialize_with = "color")]
1919 pub selection_bg: Color,
@@ -24,9 +24,17 @@ pub struct Theme {
2424 #[serde(deserialize_with = "color")]
2525 pub directory: Color,
2626 #[serde(deserialize_with = "color")]
27− pub border: Color,
28− #[serde(deserialize_with = "color")]
2927 pub error: Color,
28+ #[serde(deserialize_with = "color")]
29+ pub artist: Color,
30+ #[serde(deserialize_with = "color")]
31+ pub track: Color,
32+ #[serde(deserialize_with = "color")]
33+ pub album: Color,
34+ #[serde(deserialize_with = "color")]
35+ pub duration: Color,
36+ #[serde(deserialize_with = "color", rename = "progress")]
37+ pub progress_filled: Color,
3038 #[serde(deserialize_with = "colors")]
3139 pub visualizer: Vec<Color>,
3240 }
@@ -40,22 +48,27 @@ impl Theme {
4048 Style::new().fg(self.dim)
4149 }
4250
43− pub fn accent(&self) -> Style {
44− Style::new().fg(self.accent)
45− }
46−
4751 pub fn border(&self) -> Style {
4852 Style::new().fg(self.border)
4953 }
5054
51− /// The highlighted row of a focused list.
55+ /// Titles and column headers.
56+ pub fn header(&self) -> Style {
57+ self.text().add_modifier(Modifier::BOLD)
58+ }
59+
60+ /// The selected row of the focused list.
5261 pub fn selection(&self) -> Style {
53− Style::new().fg(self.selection_fg).bg(self.selection_bg)
62+ if self.selection_fg == Color::Reset && self.selection_bg == Color::Reset {
63+ Style::new().add_modifier(Modifier::REVERSED)
64+ } else {
65+ Style::new().fg(self.selection_fg).bg(self.selection_bg)
66+ }
5467 }
5568
56− /// The highlighted row of a list without focus.
69+ /// The selected row of a list without focus.
5770 pub fn selection_inactive(&self) -> Style {
58− Style::new().add_modifier(Modifier::REVERSED)
71+ self.selection().add_modifier(Modifier::DIM)
5972 }
6073
6174 pub fn playing(&self) -> Style {
@@ -67,7 +80,7 @@ impl Theme {
6780 }
6881
6982 pub fn directory(&self) -> Style {
70− Style::new().fg(self.directory).add_modifier(Modifier::BOLD)
83+ Style::new().fg(self.directory)
7184 }
7285
7386 pub fn error(&self) -> Style {
M src/mpd/connection.rs View
@@ -20,6 +20,23 @@ pub enum Address {
2020 Unix(PathBuf),
2121 }
2222
23+impl Address {
24+ /// Whether the server runs on this machine.
25+ pub fn is_local(&self) -> bool {
26+ match self {
27+ Self::Unix(_) => true,
28+ Self::Tcp(addr) => {
29+ let host = addr
30+ .rsplit_once(':')
31+ .map_or(addr.as_str(), |(host, _)| host)
32+ .trim_start_matches('[')
33+ .trim_end_matches(']');
34+ matches!(host, "localhost" | "127.0.0.1" | "::1") || host.starts_with("127.")
35+ }
36+ }
37+ }
38+}
39+
2340 impl fmt::Display for Address {
2441 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2542 match self {
M src/popups/add_to_playlist.rs View
@@ -2,7 +2,7 @@ use ratatui::{
22 Frame,
33 layout::{Constraint, Rect},
44 text::Span,
5− widgets::{Block, Row},
5+ widgets::Row,
66 };
77
88 use super::{InputPopup, InputPurpose, Popup, PopupState, frame_popup};
@@ -81,7 +81,6 @@ impl Popup for AddToPlaylistPopup {
8181 self.list.render(
8282 frame,
8383 inner,
84− Block::new(),
8584 None,
8685 &[Constraint::Fill(1)],
8786 choices.len(),
@@ -89,8 +88,8 @@ impl Popup for AddToPlaylistPopup {
8988 theme,
9089 |i| {
9190 Row::new([match &choices[i] {
92− Choice::Queue => Span::styled("Current queue", theme.accent()),
93− Choice::NewPlaylist => Span::styled("New playlist…", theme.accent()),
91+ Choice::Queue => Span::styled("Current queue", theme.header()),
92+ Choice::NewPlaylist => Span::styled("New playlist…", theme.header()),
9493 Choice::Playlist(name) => Span::styled(name.clone(), theme.text()),
9594 }])
9695 },
M src/popups/confirm.rs View
@@ -37,9 +37,9 @@ impl Popup for ConfirmPopup {
3737 Line::from(self.message.as_str()),
3838 Line::default(),
3939 Line::from(vec![
40− Span::styled("[y]", theme.accent()),
40+ Span::styled("[y]", theme.header()),
4141 Span::raw("es "),
42− Span::styled("[n]", theme.accent()),
42+ Span::styled("[n]", theme.header()),
4343 Span::raw("o"),
4444 ]),
4545 ];
M src/popups/help.rs View
@@ -2,7 +2,7 @@ use ratatui::{
22 Frame,
33 layout::{Constraint, Rect},
44 text::Span,
5− widgets::{Block, Row},
5+ widgets::Row,
66 };
77
88 use super::{Popup, PopupState, frame_popup};
@@ -101,14 +101,13 @@ impl Popup for HelpPopup {
101101 self.list.render(
102102 frame,
103103 inner,
104− Block::new(),
105104 None,
106105 &[Constraint::Length(24), Constraint::Fill(1)],
107106 rows.len(),
108107 true,
109108 theme,
110109 |i| match &rows[i] {
111− HelpRow::Heading(title) => Row::new([Span::styled(title.clone(), theme.accent())]),
110+ HelpRow::Heading(title) => Row::new([Span::styled(title.clone(), theme.header())]),
112111 HelpRow::Binding { keys, description } => Row::new([
113112 Span::styled(format!(" {keys}"), theme.text()),
114113 Span::styled(*description, theme.dim()),
M src/popups/input.rs View
@@ -17,6 +17,7 @@ pub enum InputPurpose {
1717 RenamePlaylist(String),
1818 SaveQueue,
1919 TagValue(TagField),
20+ FileName,
2021 }
2122
2223 pub struct InputPopup {
@@ -57,6 +58,10 @@ impl InputPopup {
5758 ctx.client.exec(commands::save(&value));
5859 ctx.notify(format!("Saved the queue as “{value}”"));
5960 }
61+ InputPurpose::FileName => ctx.effects.push(Effect::Message(
62+ ScreenId::TagEditor,
63+ ScreenMessage::FileName(value),
64+ )),
6065 InputPurpose::TagValue(field) => ctx.effects.push(Effect::Message(
6166 ScreenId::TagEditor,
6267 ScreenMessage::TagValue {
M src/popups/mod.rs View
@@ -50,9 +50,9 @@ fn frame_popup(
5050 .flex(Flex::Center)
5151 .areas(area);
5252 let block = Block::bordered()
53− .border_type(BorderType::Rounded)
54− .border_style(ctx.theme().accent())
55− .title(Line::from(format!(" {title} ")).style(ctx.theme().accent()));
53+ .border_type(BorderType::Plain)
54+ .border_style(ctx.theme().border())
55+ .title(Line::from(format!(" {title} ")).style(ctx.theme().header()));
5656 let inner = block.inner(area);
5757 frame.render_widget(Clear, area);
5858 frame.render_widget(block, area);
M src/screens/browser.rs View
@@ -6,8 +6,8 @@ use std::collections::{HashMap, HashSet};
66 use crossterm::event::MouseEvent;
77 use ratatui::{
88 Frame,
9− layout::{Constraint, Rect},
10− style::Modifier,
9+ layout::{Constraint, Layout, Rect},
10+ style::{Modifier, Style},
1111 text::{Line, Span},
1212 widgets::{Cell, Row},
1313 };
@@ -19,9 +19,8 @@ use crate::{
1919 popups::AddToPlaylistPopup,
2020 ui::{
2121 self,
22− list::{Click, ListView},
22+ list::{Click, Header, ListView},
2323 },
24− util::format_duration,
2524 };
2625
2726 #[derive(Debug, Default)]
@@ -175,70 +174,62 @@ fn sort_entries(entries: &mut [DirEntry]) {
175174 }
176175
177176 impl Screen for BrowserScreen {
177+ fn title(&self, _ctx: &Ctx) -> Line<'static> {
178+ Line::from(format!("Browse: /{}", ui::clean(&self.path)))
179+ }
180+
178181 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
179182 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− }
183+ let columns = &ctx.config.ui.song_columns;
184+ let span = columns.len() as u16;
196185 let entries = &self.entries;
197186 let marks = &self.marks;
198187 self.list.render(
199188 frame,
200189 area,
201− ui::pane(title, true, theme),
202− None,
203− &[Constraint::Fill(1), Constraint::Length(8)],
190+ Some(Header::Columns(ui::song_header(columns))),
191+ &ui::song_widths(columns),
204192 entries.len(),
205193 true,
206194 theme,
207195 |i| {
208− let (label, duration, style) = match &entries[i] {
209− DirEntry::Directory(_) => (
210− format!("▸ {}/", entries[i].name()),
211− String::new(),
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())),
212200 theme.directory(),
213− ),
201+ ))
202+ .column_span(span)]),
214203 DirEntry::Song(song) => {
215− let style = if ctx.mpd.is_queued(&song.file) {
216− theme.text().add_modifier(Modifier::BOLD)
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))
217207 } 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())
208+ row
209+ }
228210 }
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)]),
229216 };
230− let style = if marks.contains(&i) {
231− theme.marked()
217+ if marks.contains(&i) {
218+ row.style(theme.marked())
232219 } else {
233− style
234− };
235− Row::new([
236− Cell::from(label),
237− Cell::from(Span::styled(duration, theme.dim())),
238− ])
239− .style(style)
220+ row
221+ }
240222 },
241223 );
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+ }
242233 }
243234
244235 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
M src/screens/library.rs View
@@ -3,9 +3,9 @@
33 use crossterm::event::MouseEvent;
44 use ratatui::{
55 Frame,
6− layout::{Constraint, Layout, Position, Rect},
7− style::Modifier,
8− text::Span,
6+ layout::{Constraint, Position, Rect},
7+ style::{Modifier, Style},
8+ text::{Line, Span},
99 widgets::{Cell, Row},
1010 };
1111
@@ -19,7 +19,7 @@ use crate::{
1919 popups::AddToPlaylistPopup,
2020 ui::{
2121 self,
22− list::{Click, ListView},
22+ list::{Click, Header, ListView},
2323 },
2424 util::format_duration,
2525 };
@@ -172,112 +172,95 @@ fn unknown(value: &str) -> &str {
172172 }
173173
174174 impl Screen for LibraryScreen {
175+ fn title(&self, _ctx: &Ctx) -> Line<'static> {
176+ Line::from("Media library")
177+ }
178+
175179 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
176180 let theme = ctx.theme();
177− let [artists_area, albums_area, songs_area] = Layout::horizontal([
178− Constraint::Percentage(28),
179− Constraint::Percentage(32),
180− Constraint::Percentage(40),
181− ])
182− .areas(area);
181+ let [artists_area, albums_area, songs_area] = <[Rect; 3]>::try_from(ui::split_columns(
182+ frame,
183+ area,
184+ &[
185+ Constraint::Percentage(28),
186+ Constraint::Percentage(32),
187+ Constraint::Percentage(40),
188+ ],
189+ theme,
190+ ))
191+ .expect("three columns");
183192
184193 let artists = &self.artists;
185194 let title = if self.use_artist {
186− " Artists "
195+ "Artists"
187196 } else {
188− " Album Artists "
197+ "Album artists"
189198 };
190− let block = ui::pane(title, self.focus == Column::Artists, theme);
191− if artists.is_empty() {
192− let inner = block.inner(artists_area);
193− frame.render_widget(block, artists_area);
194− let text = if self.artists_loaded {
195− "No artists"
196− } else {
197− "Loading…"
198− };
199− ui::draw_placeholder(frame, inner, &[text], theme);
200− } else {
201− self.artist_list.render(
202− frame,
203− artists_area,
204− block,
205− None,
206− &[Constraint::Fill(1)],
207− artists.len(),
208− self.focus == Column::Artists,
209− theme,
210− |i| Row::new([Cell::from(unknown(&artists[i]).to_owned())]),
211− );
212− }
199+ self.artist_list.render(
200+ frame,
201+ artists_area,
202+ Some(Header::Title(Line::from(title))),
203+ &[Constraint::Fill(1)],
204+ artists.len(),
205+ self.focus == Column::Artists,
206+ theme,
207+ |i| Row::new([Cell::from(ui::clean(unknown(&artists[i])))]),
208+ );
213209
214210 let albums = &self.albums;
215− let block = ui::pane(" Albums ", self.focus == Column::Albums, theme);
216− if albums.is_empty() {
217− frame.render_widget(block, albums_area);
218− } else {
219− self.album_list.render(
220− frame,
221− albums_area,
222− block,
223− None,
224− &[Constraint::Length(6), Constraint::Fill(1)],
225− albums.len(),
226− self.focus == Column::Albums,
227− theme,
228− |i| {
229− let album = &albums[i];
230− Row::new([
231− Cell::from(Span::styled(
232− album.date.chars().take(4).collect::<String>(),
233− theme.dim(),
234− )),
235− Cell::from(unknown(&album.name).to_owned()),
236− ])
237− },
238− );
239− }
211+ self.album_list.render(
212+ frame,
213+ albums_area,
214+ Some(Header::Title(Line::from("Albums"))),
215+ &[Constraint::Fill(1)],
216+ albums.len(),
217+ self.focus == Column::Albums,
218+ theme,
219+ |i| {
220+ let album = &albums[i];
221+ let date: String = album.date.chars().take(4).collect();
222+ let mut spans = Vec::new();
223+ if !date.is_empty() {
224+ spans.push(Span::styled(format!("({date}) "), theme.dim()));
225+ }
226+ spans.push(Span::raw(ui::clean(unknown(&album.name))));
227+ Row::new([Cell::from(Line::from(spans))])
228+ },
229+ );
240230
241231 let songs = &self.songs;
242− let block = ui::pane(" Songs ", self.focus == Column::Songs, theme);
243− if songs.is_empty() {
244− frame.render_widget(block, songs_area);
245− } else {
246− self.song_list.render(
247− frame,
248− songs_area,
249− block,
250− None,
251− &[
252− Constraint::Length(3),
253− Constraint::Fill(1),
254− Constraint::Length(7),
255− ],
256− songs.len(),
257− self.focus == Column::Songs,
258− theme,
259− |i| {
260− let song = &songs[i];
261− let style = if ctx.mpd.is_queued(&song.file) {
262− theme.text().add_modifier(Modifier::BOLD)
263− } else {
264− theme.text()
265− };
266− Row::new([
267− Cell::from(Span::styled(
268− song.track().unwrap_or_default().to_owned(),
269− theme.dim(),
270− )),
271− Cell::from(song.display_title().to_owned()),
272− Cell::from(Span::styled(
273− song.duration.map(format_duration).unwrap_or_default(),
274− theme.dim(),
275− )),
276− ])
277− .style(style)
278− },
279− );
280− }
232+ self.song_list.render(
233+ frame,
234+ songs_area,
235+ Some(Header::Title(Line::from("Songs"))),
236+ &[
237+ Constraint::Length(2),
238+ Constraint::Fill(1),
239+ Constraint::Length(5),
240+ ],
241+ songs.len(),
242+ self.focus == Column::Songs,
243+ theme,
244+ |i| {
245+ let song = &songs[i];
246+ let row = Row::new([
247+ Cell::from(Span::styled(
248+ song.track().map(|t| format!("{t:0>2}")).unwrap_or_default(),
249+ Style::new().fg(theme.track),
250+ )),
251+ Cell::from(ui::clean(song.display_title())),
252+ Cell::from(
253+ Line::from(song.duration.map(format_duration).unwrap_or_default())
254+ .right_aligned(),
255+ ),
256+ ]);
257+ if ctx.mpd.is_queued(&song.file) {
258+ row.style(Style::new().add_modifier(Modifier::BOLD))
259+ } else {
260+ row
261+ }
262+ },
263+ );
281264 }
282265
283266 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
M src/screens/lyrics.rs View
@@ -3,8 +3,7 @@
33 use crossterm::event::{MouseEvent, MouseEventKind};
44 use ratatui::{
55 Frame,
6− layout::{Alignment, Rect},
7− style::Modifier,
6+ layout::{Alignment, Constraint, Layout, Rect},
87 text::{Line, Span},
98 widgets::Paragraph,
109 };
@@ -24,20 +23,22 @@ pub struct LyricsScreen {
2423 file: Option<String>,
2524 }
2625
27−/// Draws the lyrics pane without manual scrolling.
28−pub fn draw_lyrics(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool) {
29− render(frame, area, ctx, focused, 0);
26+/// Draws the lyrics pane next to the queue (no manual scrolling).
27+pub fn draw_lyrics(frame: &mut Frame, area: Rect, ctx: &Ctx) {
28+ let theme = ctx.theme();
29+ let [header, line, body] = Layout::vertical([
30+ Constraint::Length(1),
31+ Constraint::Length(1),
32+ Constraint::Fill(1),
33+ ])
34+ .areas(area);
35+ frame.render_widget(Line::styled("Lyrics", theme.header()), header);
36+ ui::draw_hline(frame, line, theme);
37+ render(frame, body, ctx, 0);
3038 }
3139
32−fn render(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool, scroll: usize) -> usize {
40+fn render(frame: &mut Frame, inner: Rect, ctx: &Ctx, scroll: usize) -> usize {
3341 let theme = ctx.theme();
34− let title = match &ctx.mpd.current {
35− Some(song) => format!(" Lyrics · {} ", ui::song_label(song)),
36− None => " Lyrics ".to_owned(),
37− };
38− let block = ui::pane(title, focused, theme);
39− let inner = block.inner(area);
40− frame.render_widget(block, area);
4142 let height = usize::from(inner.height);
4243
4344 let lyrics = match (&ctx.mpd.current, &ctx.lyrics.status) {
@@ -71,9 +72,7 @@ fn render(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool, scroll: usize
7172 .map(|i| {
7273 let text = lyrics.line(i);
7374 let style = match (lyrics, current) {
74− (Lyrics::Synced(_), Some(c)) if i == c => {
75− theme.playing().add_modifier(Modifier::BOLD)
76− }
75+ (Lyrics::Synced(_), Some(c)) if i == c => theme.header(),
7776 (Lyrics::Synced(_), Some(c)) if i < c => theme.dim(),
7877 (Lyrics::Synced(_), None) => theme.dim(),
7978 _ => theme.text(),
@@ -86,6 +85,13 @@ fn render(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool, scroll: usize
8685 }
8786
8887 impl Screen for LyricsScreen {
88+ fn title(&self, ctx: &Ctx) -> Line<'static> {
89+ match &ctx.mpd.current {
90+ Some(song) => Line::from(format!("Lyrics: {}", ui::song_label(song))),
91+ None => Line::from("Lyrics"),
92+ }
93+ }
94+
8995 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
9096 // Plain lyrics start from the top for every song.
9197 let file = ctx.mpd.current.as_ref().map(|s| s.file.clone());
@@ -93,7 +99,7 @@ impl Screen for LyricsScreen {
9399 self.file = file;
94100 self.scroll = 0;
95101 }
96− self.height = render(frame, area, ctx, true, self.scroll);
102+ self.height = render(frame, area, ctx, self.scroll);
97103 }
98104
99105 fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool {
M src/screens/mod.rs View
@@ -12,7 +12,7 @@ mod visualizer;
1212 use std::sync::Arc;
1313
1414 use crossterm::event::MouseEvent;
15−use ratatui::{Frame, layout::Rect};
15+use ratatui::{Frame, layout::Rect, text::Line};
1616 use tokio::sync::watch;
1717
1818 pub use browser::BrowserScreen;
@@ -95,9 +95,13 @@ pub enum Effect {
9595 #[derive(Clone, Debug)]
9696 pub enum ScreenMessage {
9797 TagValue { field: TagField, value: String },
98+ FileName(String),
9899 }
99100
100101 pub trait Screen {
102+ /// The text of the title row.
103+ fn title(&self, ctx: &Ctx) -> Line<'static>;
104+
101105 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx);
102106
103107 /// Returns whether the action was handled.
M src/screens/playlists.rs View
@@ -3,8 +3,8 @@
33 use crossterm::event::MouseEvent;
44 use ratatui::{
55 Frame,
6− layout::{Constraint, Layout, Position, Rect},
7− text::{Line, Span},
6+ layout::{Constraint, Position, Rect},
7+ text::Line,
88 widgets::{Cell, Row},
99 };
1010
@@ -15,9 +15,8 @@ use crate::{
1515 popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose},
1616 ui::{
1717 self,
18− list::{Click, ListView},
18+ list::{Click, Header, ListView},
1919 },
20− util::format_duration,
2120 };
2221
2322 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -102,102 +101,51 @@ impl PlaylistsScreen {
102101 }
103102
104103 impl Screen for PlaylistsScreen {
104+ fn title(&self, _ctx: &Ctx) -> Line<'static> {
105+ Line::from("Playlist editor")
106+ }
107+
105108 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
106109 let theme = ctx.theme();
107− let [left, right] =
108− Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)])
109− .areas(area);
110+ let [left, right] = <[Rect; 2]>::try_from(ui::split_columns(
111+ frame,
112+ area,
113+ &[Constraint::Percentage(25), Constraint::Percentage(75)],
114+ theme,
115+ ))
116+ .expect("two columns");
110117
111118 let playlists = &ctx.mpd.playlists;
112119 let list_focused = self.focus == Focus::Playlists;
113− let block = ui::pane(
114− format!(" Playlists ({}) ", playlists.len()),
120+ self.playlists.render(
121+ frame,
122+ left,
123+ Some(Header::Title(Line::from("Playlists"))),
124+ &[Constraint::Fill(1)],
125+ playlists.len(),
115126 list_focused,
116127 theme,
128+ |i| Row::new([Cell::from(ui::clean(&playlists[i].name))]),
117129 );
118− if playlists.is_empty() {
119− let inner = block.inner(left);
120− frame.render_widget(block, left);
121− ui::draw_placeholder(
122− frame,
123− inner,
124− &["No playlists", "", "Create one with `a` on any song"],
125− theme,
126− );
127− } else {
128− self.playlists.render(
129− frame,
130− left,
131− block,
132− None,
133− &[Constraint::Fill(1)],
134− playlists.len(),
135− list_focused,
136− theme,
137− |i| Row::new([Cell::from(playlists[i].name.as_str())]),
138− );
139− }
140130
141131 let contents = &self.contents;
142− let total: std::time::Duration = contents.iter().filter_map(|s| s.duration).sum();
143− let title = match &self.songs_of {
144− Some(name) => Line::from(vec![
145− Span::raw(format!(" {name} ")),
146− Span::styled(
147− format!("{} songs · {} ", contents.len(), format_duration(total)),
148− theme.dim(),
149− ),
150− ]),
151− None => Line::from(" Songs "),
152− };
153− let block = ui::pane(title, !list_focused, theme);
154− if contents.is_empty() {
155− let inner = block.inner(right);
156− frame.render_widget(block, right);
157− let text = if self.songs_of.is_some() {
158− "This playlist is empty"
159− } else {
160− ""
161− };
162− ui::draw_placeholder(frame, inner, &[text], theme);
163− return;
164− }
132+ let columns = &ctx.config.ui.song_columns;
165133 self.songs.render(
166134 frame,
167135 right,
168− block,
169− None,
170− &[
171− Constraint::Fill(3),
172− Constraint::Fill(4),
173− Constraint::Fill(3),
174− Constraint::Length(7),
175− ],
136+ Some(Header::Columns(ui::song_header(columns))),
137+ &ui::song_widths(columns),
176138 contents.len(),
177139 !list_focused,
178140 theme,
179141 |i| {
180142 let song = &contents[i];
181− let style = if ctx
143+ let playing = ctx
182144 .mpd
183145 .current
184146 .as_ref()
185− .is_some_and(|c| c.file == song.file)
186− {
187− theme.playing()
188− } else {
189− theme.text()
190− };
191− Row::new([
192− Cell::from(song.artist().unwrap_or_default().to_owned()),
193− Cell::from(song.display_title().to_owned()),
194− Cell::from(song.album().unwrap_or_default().to_owned()),
195− Cell::from(Span::styled(
196− song.duration.map(format_duration).unwrap_or_default(),
197− theme.dim(),
198− )),
199− ])
200− .style(style)
147+ .is_some_and(|c| c.file == song.file);
148+ ui::song_row(song, columns, playing, theme)
201149 },
202150 );
203151 }
M src/screens/queue.rs View
@@ -5,23 +5,21 @@ use std::{collections::HashSet, time::Duration};
55 use crossterm::event::MouseEvent;
66 use ratatui::{
77 Frame,
8− layout::{Constraint, Layout, Rect},
9− style::Modifier,
10− text::{Line, Span},
11− widgets::{Cell, Row},
8+ layout::{Constraint, Rect},
9+ text::Line,
1210 };
1311
1412 use super::{Ctx, Screen, draw_lyrics};
1513 use crate::{
1614 action::Action,
17− config::{ColumnField, Config},
15+ config::Config,
1816 mpd::{MpdEvent, Reply, Selection, commands},
19− popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose},
17+ popups::{AddToPlaylistPopup, InputPopup, InputPurpose},
2018 ui::{
2119 self,
22− list::{Click, ListView},
20+ list::{Click, Header, ListView},
2321 },
24− util::format_duration,
22+ util::{format_length, plural},
2523 };
2624
2725 pub struct QueueScreen {
@@ -98,93 +96,55 @@ impl QueueScreen {
9896 }
9997
10098 impl Screen for QueueScreen {
99+ fn title(&self, ctx: &Ctx) -> Line<'static> {
100+ let queue = &ctx.mpd.queue;
101+ let total: Duration = queue.iter().filter_map(|s| s.duration).sum();
102+ Line::from(format!(
103+ "Playlist ({}, length: {})",
104+ plural(queue.len(), "item"),
105+ format_length(total)
106+ ))
107+ }
108+
101109 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
110+ let theme = ctx.theme();
102111 let area = if self.lyrics_pane {
103112 let width = ctx.config.ui.lyrics_pane_width;
104− let [queue, lyrics] = Layout::horizontal([
105− Constraint::Percentage(100 - width),
106− Constraint::Percentage(width),
107− ])
108− .areas(area);
109− draw_lyrics(frame, lyrics, ctx, false);
113+ let [queue, lyrics] = <[Rect; 2]>::try_from(ui::split_columns(
114+ frame,
115+ area,
116+ &[
117+ Constraint::Percentage(100 - width),
118+ Constraint::Percentage(width),
119+ ],
120+ theme,
121+ ))
122+ .expect("two columns");
123+ draw_lyrics(frame, lyrics, ctx);
110124 queue
111125 } else {
112126 area
113127 };
114128
115− let theme = ctx.theme();
116129 let queue = &ctx.mpd.queue;
117− let columns = &ctx.config.ui.queue_columns;
118− let total: Duration = queue.iter().filter_map(|s| s.duration).sum();
119− let title = Line::from(vec![
120− Span::raw(" Queue "),
121− Span::styled(
122− format!("{} songs · {} ", queue.len(), format_duration(total)),
123− theme.dim(),
124− ),
125− ]);
126− let mut widths = vec![Constraint::Length(1)];
127− widths.extend(columns.iter().map(|c| match c.field {
128− ColumnField::Duration => Constraint::Length(c.width.max(5)),
129− ColumnField::Track | ColumnField::Disc => Constraint::Length(c.width.max(2)),
130− _ => Constraint::Fill(c.width),
131− }));
132− let header = Row::new(
133− std::iter::once(Cell::from(""))
134− .chain(columns.iter().map(|c| Cell::from(c.field.title()))),
135− )
136− .style(theme.dim().add_modifier(Modifier::BOLD));
137−
138− if queue.is_empty() {
139− let block = ui::pane(title, true, theme);
140− let inner = block.inner(area);
141− frame.render_widget(block, area);
142− ui::draw_placeholder(
143− frame,
144− inner,
145− &[
146− "The queue is empty.",
147− "",
148− "Add songs from the Browser (2) or the Library (4).",
149− ],
150− theme,
151− );
152− return;
153− }
154−
130+ let columns = &ctx.config.ui.song_columns;
155131 let marks = &self.marks;
156132 self.list.render(
157133 frame,
158134 area,
159− ui::pane(title, true, theme),
160− Some(header),
161− &widths,
135+ Some(Header::Columns(ui::song_header(columns))),
136+ &ui::song_widths(columns),
162137 queue.len(),
163138 true,
164139 theme,
165140 |i| {
166141 let song = &queue[i];
167− let marked = song.id.is_some_and(|id| marks.contains(&id));
168− let (marker, style) = if ctx.mpd.is_current(song) {
169− ("▶", theme.playing())
170− } else if marked {
171− ("●", theme.marked())
172− } else {
173− (" ", theme.text())
174− };
175− let style = if marked {
176− style.patch(theme.marked())
142+ let row = ui::song_row(song, columns, ctx.mpd.is_current(song), theme);
143+ if song.id.is_some_and(|id| marks.contains(&id)) {
144+ row.style(theme.marked())
177145 } else {
178− style
179− };
180− Row::new(
181− std::iter::once(Cell::from(Span::styled(marker, style))).chain(
182− columns
183− .iter()
184− .map(|c| Cell::from(ui::song_field(song, c.field))),
185− ),
186− )
187− .style(style)
146+ row
147+ }
188148 },
189149 );
190150 }
@@ -211,12 +171,6 @@ impl Screen for QueueScreen {
211171 }
212172 Action::MoveItemUp => self.move_selected(false, ctx),
213173 Action::MoveItemDown => self.move_selected(true, ctx),
214− Action::ClearQueue if len > 0 => {
215− ctx.popup(ConfirmPopup::new(
216− format!("Remove all {len} songs from the queue?"),
217− vec![commands::clear()],
218− ));
219− }
220174 Action::ShuffleQueue => ctx.client.exec(commands::shuffle()),
221175 Action::SaveQueue if len > 0 => {
222176 ctx.popup(InputPopup::new(
M src/screens/tag_editor.rs View
@@ -1,15 +1,19 @@
1−//! Screen 5: editing tags of local files.
1+//! Screen 5: the tag editor, laid out like ncmpcpp's.
22 //!
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.
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.
57
6−use std::collections::{BTreeMap, HashMap, HashSet};
8+use std::collections::{BTreeMap, HashSet};
79
810 use crossterm::event::MouseEvent;
911 use ratatui::{
1012 Frame,
11− layout::{Constraint, Layout, Position, Rect},
12− text::{Line, Span},
13+ layout::{Constraint, Position, Rect},
14+ style::Modifier,
15+ symbols,
16+ text::Line,
1317 widgets::{Cell, Row},
1418 };
1519
@@ -18,37 +22,110 @@ use crate::{
1822 action::Action,
1923 mpd::{DirEntry, MpdEvent, Query, Reply, Song, Subsystem},
2024 popups::{InputPopup, InputPurpose},
21− tags::{TagField, TagWrite},
25+ tags::{self, TagField, TagWrite},
2226 ui::{
2327 self,
24− list::{Click, ListView},
28+ list::{Click, Header, ListView},
2529 },
2630 };
2731
2832 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
2933 enum Pane {
3034 #[default]
31− Folders,
32− Songs,
33− Fields,
35+ Directories,
36+ TagTypes,
37+ Tags,
38+}
39+
40+/// The rows of the `Tag types` column.
41+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42+enum Item {
43+ Field(TagField),
44+ Separator,
45+ FileName,
46+ Capitalize,
47+ Lowercase,
48+ Reset,
49+ Save,
50+}
51+
52+const 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+
75+impl 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)]
91+struct Changes {
92+ tags: BTreeMap<TagField, String>,
93+ rename: Option<String>,
94+}
95+
96+impl 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)]
104+enum Editing {
105+ /// A tag of these files.
106+ Field(Vec<String>),
107+ FileName(String),
34108 }
35109
36110 #[derive(Debug, Default)]
37111 pub struct TagEditorScreen {
38112 focus: Pane,
113+ /// The directory whose subdirectories are listed.
39114 path: String,
40115 stack: Vec<(String, usize)>,
41116 restore: Option<usize>,
42117 loaded: bool,
43118 folders: Vec<String>,
119+ /// The directory whose songs are shown in `Tags`.
120+ shown: Option<String>,
44121 songs: Vec<Song>,
45− folder_list: ListView,
46− song_list: ListView,
47− field_list: ListView,
48− /// Marked songs by index.
122+ dirs: ListView,
123+ items: ListView,
124+ tags: ListView,
49125 marks: HashSet<usize>,
50− /// Pending edits by file.
51− pending: HashMap<String, BTreeMap<TagField, String>>,
126+ /// Pending changes by file.
127+ pending: BTreeMap<String, Changes>,
128+ editing: Option<Editing>,
52129 }
53130
54131 impl TagEditorScreen {
@@ -56,9 +133,23 @@ impl TagEditorScreen {
56133 !self.path.is_empty()
57134 }
58135
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())
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+ }
62153 }
63154
64155 fn load(&mut self, ctx: &Ctx) {
@@ -66,43 +157,81 @@ impl TagEditorScreen {
66157 ctx.client.query(Query::Directory(self.path.clone()));
67158 }
68159
69− fn open_selected_folder(&mut self, ctx: &Ctx) {
70− let Some(index) = self.folder_list.selected(self.folder_rows()) else {
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 {
71179 return;
72180 };
73− if self.has_parent() && index == 0 {
74− self.back(ctx);
75− return;
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+ }
76195 }
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);
82196 }
83197
84198 fn back(&mut self, ctx: &Ctx) {
85199 if let Some((parent, index)) = self.stack.pop() {
86200 self.path = parent;
87− self.reset_lists();
201+ self.dirs.reset();
202+ self.folders.clear();
88203 self.restore = Some(index);
89204 self.load(ctx);
90205 }
91206 }
92207
93− fn reset_lists(&mut self) {
94− self.folder_list.reset();
95− self.song_list.reset();
96− self.marks.clear();
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
97229 }
98230
99− /// Indices of the songs an edit applies to: marked ones, or the selected.
231+ /// The songs an action applies to: marked ones, or all shown songs.
100232 fn targets(&self) -> Vec<usize> {
101233 if self.marks.is_empty() {
102− self.song_list
103− .selected(self.songs.len())
104− .into_iter()
105− .collect()
234+ (0..self.songs.len()).collect()
106235 } else {
107236 let mut marks: Vec<_> = self.marks.iter().copied().collect();
108237 marks.sort_unstable();
@@ -114,36 +243,66 @@ impl TagEditorScreen {
114243 fn value<'a>(&'a self, song: &'a Song, field: TagField) -> &'a str {
115244 self.pending
116245 .get(&song.file)
117− .and_then(|changes| changes.get(&field))
246+ .and_then(|changes| changes.tags.get(&field))
118247 .map(String::as_str)
119248 .unwrap_or_else(|| song.tag(field.mpd_tag()).unwrap_or_default())
120249 }
121250
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())
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())
130256 }
131257
132− fn edit_field(&mut self, ctx: &mut Ctx) {
133− if self.targets().is_empty() {
258+ fn set_value(&mut self, index: usize, field: TagField, value: String) {
259+ let Some(song) = self.songs.get(index) else {
134260 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);
135268 }
136− let Some(index) = self.field_list.selected(TagField::ALL.len()) else {
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 {
137276 return;
138277 };
139− let field = TagField::ALL[index];
140− let count = self.targets().len();
141− let title = if count > 1 {
142− format!("{} ({count} songs)", field.label())
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()
143293 } else {
144− field.label().to_owned()
294+ String::new()
295+ };
296+ let title = match indices.len() {
297+ 1 => field.label().to_owned(),
298+ n => format!("{} ({n} songs)", field.label()),
145299 };
146− let initial = self.shared_value(field).unwrap_or_default();
300+ self.editing = Some(Editing::Field(
301+ indices
302+ .iter()
303+ .map(|&i| self.songs[i].file.clone())
304+ .collect(),
305+ ));
147306 ctx.popup(InputPopup::new(
148307 title,
149308 &initial,
@@ -151,6 +310,53 @@ impl TagEditorScreen {
151310 ));
152311 }
153312
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+
154360 fn save(&mut self, ctx: &mut Ctx) {
155361 if self.pending.is_empty() {
156362 ctx.notify("No pending tag changes");
@@ -160,13 +366,13 @@ impl TagEditorScreen {
160366 ctx.error("Set mpd.music_directory in the config to write tags");
161367 return;
162368 };
163− let writes = self
164− .pending
165− .drain()
369+ let writes = std::mem::take(&mut self.pending)
370+ .into_iter()
166371 .map(|(uri, changes)| TagWrite {
167372 path: music_dir.join(&uri),
168373 uri,
169− changes,
374+ changes: changes.tags,
375+ rename: changes.rename,
170376 })
171377 .collect();
172378 ctx.effects.push(Effect::WriteTags(writes));
@@ -174,141 +380,112 @@ impl TagEditorScreen {
174380 }
175381
176382 impl 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+
177391 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
178392 let theme = ctx.theme();
179393 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);
183394 ui::draw_placeholder(
184395 frame,
185− inner,
396+ area,
186397 &[
187398 "The tag editor needs access to the music files.",
188399 "",
189− "Set `music_directory` in the [mpd] section of the config,",
190− "or connect to MPD through its local Unix socket.",
400+ "Set `music_directory` in the [mpd] section of the rmptui config,",
401+ "or in the mpd.conf of a local MPD.",
191402 ],
192403 theme,
193404 );
194405 return;
195406 }
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);
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");
202418
203419 let parent = self.has_parent();
204420 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(
421+ self.dirs.render(
210422 frame,
211− folders_area,
212− ui::pane(title, self.focus == Pane::Folders, theme),
213− None,
423+ dirs_area,
424+ Some(Header::Title(Line::from("Directories"))),
214425 &[Constraint::Fill(1)],
215− folders.len() + usize::from(parent),
216− self.focus == Pane::Folders,
426+ self.dir_rows(),
427+ self.focus == Pane::Directories,
217428 theme,
218429 |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))
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+ }
224437 };
225438 Row::new([Cell::from(name)]).style(theme.directory())
226439 },
227440 );
228441
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− }
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+ );
280457
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
458+ let item = self.selected_item();
459+ let rows: Vec<(String, bool)> = self
460+ .songs
288461 .iter()
289− .map(|&f| (f, self.shared_value(f)))
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+ })
290469 .collect();
291− let has_targets = !targets.is_empty();
292− self.field_list.render(
470+ let marks = &self.marks;
471+ self.tags.render(
293472 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,
473+ tags_area,
474+ Some(Header::Title(Line::from("Tags"))),
475+ &[Constraint::Fill(1)],
476+ rows.len(),
477+ self.focus == Pane::Tags,
300478 theme,
301479 |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− ])
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)
312489 },
313490 );
314491 }
@@ -327,92 +504,108 @@ impl Screen for TagEditorScreen {
327504 _ => {}
328505 }
329506 match self.focus {
330− Pane::Folders => {
331− if self.folder_list.navigate(action, self.folder_rows()) {
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) {
332524 return true;
333525 }
334526 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,
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),
338530 _ => return false,
339531 }
340532 }
341− Pane::Songs => {
342− if self.song_list.navigate(action, self.songs.len()) {
533+ Pane::Tags => {
534+ if self.tags.navigate(action, self.songs.len()) {
343535 return true;
344536 }
345537 match action {
346− Action::Left => self.focus = Pane::Folders,
347− Action::Right | Action::Activate => self.focus = Pane::Fields,
538+ Action::Left => self.focus = Pane::TagTypes,
539+ Action::Activate | Action::Right => self.activate_song(ctx),
348540 Action::ToggleMark | Action::AddToQueue => {
349− if let Some(i) = self.song_list.selected(self.songs.len()) {
541+ if let Some(i) = self.tags.selected(self.songs.len()) {
350542 if !self.marks.remove(&i) {
351543 self.marks.insert(i);
352544 }
353− self.song_list.navigate(Action::Down, self.songs.len());
545+ self.tags.navigate(Action::Down, self.songs.len());
354546 }
355547 }
356548 Action::ClearMarks => self.marks.clear(),
357549 _ => return false,
358550 }
359551 }
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− }
370552 }
371553 true
372554 }
373555
374556 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());
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+ }
384564 }
385− if changes.is_empty() {
386− self.pending.remove(&song.file);
565+ (ScreenMessage::FileName(name), Some(Editing::FileName(file))) => {
566+ self.set_file_name(&file, name);
387567 }
568+ _ => {}
388569 }
389570 }
390571
391572 fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) {
392573 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(_) => {}
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());
401588 }
589+ self.sync_songs(ctx);
402590 }
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());
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();
409599 }
410600 }
411601 MpdEvent::Changed(subsystems)
412602 if subsystems.contains(&Subsystem::Database) && self.loaded =>
413603 {
414− // Show freshly written tags.
604+ // Show freshly written tags and renamed files.
415605 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+ }
416609 }
417610 _ => {}
418611 }
@@ -421,48 +614,55 @@ impl Screen for TagEditorScreen {
421614 fn on_enter(&mut self, ctx: &mut Ctx) {
422615 if !self.loaded {
423616 self.load(ctx);
617+ self.shown = None;
618+ self.sync_songs(ctx);
424619 }
425620 }
426621
427622 fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) {
428623 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);
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);
433635 }
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);
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);
441640 }
442641 }
443642 }
444643
445− fn search(&mut self, query: &str, _ctx: &Ctx) -> bool {
644+ fn search(&mut self, query: &str, ctx: &Ctx) -> bool {
446645 match self.focus {
447− Pane::Folders => {
448− let mut names: Vec<&str> = Vec::new();
646+ Pane::Directories => {
647+ let mut names = vec![".".to_owned()];
449648 if self.has_parent() {
450− names.push("..");
649+ names.push("..".to_owned());
451650 }
452651 names.extend(
453652 self.folders
454653 .iter()
455− .map(|f| f.rsplit('/').next().unwrap_or(f)),
654+ .map(|f| f.rsplit('/').next().unwrap_or(f).to_owned()),
456655 );
457− let len = names.len();
458− self.folder_list.search(query, names.into_iter(), len);
656+ self.dirs
657+ .search(query, names.iter().map(String::as_str), names.len());
658+ self.sync_songs(ctx);
459659 }
460− Pane::Songs => {
660+ Pane::Tags => {
461661 let len = self.songs.len();
462− self.song_list
662+ self.tags
463663 .search(query, self.songs.iter().map(Song::file_name), len);
464664 }
465− Pane::Fields => return false,
665+ Pane::TagTypes => return false,
466666 }
467667 true
468668 }
M src/screens/visualizer.rs View
@@ -2,7 +2,7 @@
22
33 use std::sync::Arc;
44
5−use ratatui::{Frame, layout::Rect};
5+use ratatui::{Frame, layout::Rect, text::Line};
66 use tokio::sync::watch;
77
88 use super::{Ctx, Screen};
@@ -29,17 +29,14 @@ impl VisualizerScreen {
2929 }
3030
3131 impl Screen for VisualizerScreen {
32+ fn title(&self, _ctx: &Ctx) -> Line<'static> {
33+ Line::from("Music visualizer")
34+ }
35+
3236 fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) {
3337 let theme = ctx.theme();
3438 let config = &ctx.config.visualizer;
35− let title = match &ctx.mpd.current {
36− Some(song) => format!(" Visualizer · {} ", ui::song_label(song)),
37− None => " Visualizer ".to_owned(),
38− };
39− let block = ui::pane(title, true, theme);
40− let inner = block.inner(area);
41− frame.render_widget(block, area);
42−
39+ let inner = area;
4340 let spectrum = Arc::clone(&self.spectrum.borrow_and_update());
4441 if let Some(error) = &spectrum.error {
4542 let fifo = config.fifo.display().to_string();
M src/tags.rs View
@@ -4,30 +4,34 @@ use std::{collections::BTreeMap, path::PathBuf};
44
55 use lofty::{config::WriteOptions, prelude::*, tag::Tag};
66
7−/// The tags the editor can change.
7+/// The tags the editor can change, in ncmpcpp's order.
88 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
99 pub enum TagField {
1010 Title,
1111 Artist,
1212 AlbumArtist,
1313 Album,
14− Track,
15− Disc,
1614 Date,
15+ Track,
1716 Genre,
17+ Composer,
18+ Performer,
19+ Disc,
1820 Comment,
1921 }
2022
2123 impl TagField {
22− pub const ALL: [Self; 9] = [
24+ pub const ALL: [Self; 11] = [
2325 Self::Title,
2426 Self::Artist,
2527 Self::AlbumArtist,
2628 Self::Album,
27− Self::Track,
28− Self::Disc,
2929 Self::Date,
30+ Self::Track,
3031 Self::Genre,
32+ Self::Composer,
33+ Self::Performer,
34+ Self::Disc,
3135 Self::Comment,
3236 ];
3337
@@ -37,10 +41,12 @@ impl TagField {
3741 Self::Artist => "Artist",
3842 Self::AlbumArtist => "Album Artist",
3943 Self::Album => "Album",
40− Self::Track => "Track",
41− Self::Disc => "Disc",
4244 Self::Date => "Date",
45+ Self::Track => "Track",
4346 Self::Genre => "Genre",
47+ Self::Composer => "Composer",
48+ Self::Performer => "Performer",
49+ Self::Disc => "Disc",
4450 Self::Comment => "Comment",
4551 }
4652 }
@@ -48,65 +54,133 @@ impl TagField {
4854 /// The tag name MPD reports for this field.
4955 pub fn mpd_tag(self) -> &'static str {
5056 match self {
51− Self::Title => "Title",
52− Self::Artist => "Artist",
5357 Self::AlbumArtist => "AlbumArtist",
54− Self::Album => "Album",
55− Self::Track => "Track",
56− Self::Disc => "Disc",
57− Self::Date => "Date",
58− Self::Genre => "Genre",
59− Self::Comment => "Comment",
58+ other => other.label(),
6059 }
6160 }
6261
62+ /// Whether letter-case changes make sense for this field.
63+ pub fn is_text(self) -> bool {
64+ !matches!(self, Self::Date | Self::Track | Self::Disc)
65+ }
66+
6367 fn item_key(self) -> ItemKey {
6468 match self {
6569 Self::Title => ItemKey::TrackTitle,
6670 Self::Artist => ItemKey::TrackArtist,
6771 Self::AlbumArtist => ItemKey::AlbumArtist,
6872 Self::Album => ItemKey::AlbumTitle,
69− Self::Track => ItemKey::TrackNumber,
70− Self::Disc => ItemKey::DiscNumber,
7173 Self::Date => ItemKey::RecordingDate,
74+ Self::Track => ItemKey::TrackNumber,
7275 Self::Genre => ItemKey::Genre,
76+ Self::Composer => ItemKey::Composer,
77+ Self::Performer => ItemKey::Performer,
78+ Self::Disc => ItemKey::DiscNumber,
7379 Self::Comment => ItemKey::Comment,
7480 }
7581 }
7682 }
7783
78−/// Pending changes for one file. An empty value removes the tag.
79−#[derive(Clone, Debug, PartialEq, Eq)]
84+/// Pending changes for one file.
85+#[derive(Clone, Debug, Default, PartialEq, Eq)]
8086 pub struct TagWrite {
8187 pub path: PathBuf,
8288 /// Library-relative path, for reporting and rescanning.
8389 pub uri: String,
90+ /// New tag values; an empty value removes the tag.
8491 pub changes: BTreeMap<TagField, String>,
92+ /// A new file name (without directory), if the file is to be renamed.
93+ pub rename: Option<String>,
8594 }
8695
87−/// Writes `changes` into the file, creating its primary tag if needed.
96+/// Writes the tag changes, creating the primary tag if needed, then renames
97+/// the file if requested.
8898 pub fn write(write: &TagWrite) -> Result<(), String> {
8999 let describe = |error: &dyn std::fmt::Display| format!("{}: {error}", write.uri);
90− let mut file = lofty::read_from_path(&write.path).map_err(|e| describe(&e))?;
91− if file.primary_tag().is_none() {
92− file.insert_tag(Tag::new(file.primary_tag_type()));
100+ if let Some(name) = &write.rename
101+ && (name.trim().is_empty() || name.contains('/') || matches!(name.trim(), "." | ".."))
102+ {
103+ return Err(format!("{}: invalid file name “{name}”", write.uri));
93104 }
94− let tag = file
95− .primary_tag_mut()
96− .ok_or_else(|| format!("{}: cannot create a tag", write.uri))?;
97− for (field, value) in &write.changes {
98− let value = value.trim();
99− if value.is_empty() {
100− tag.remove_key(field.item_key());
101− } else if !tag.insert_text(field.item_key(), value.to_owned()) {
102− return Err(format!(
103− "{}: the {:?} tag cannot hold {}",
104− write.uri,
105− tag.tag_type(),
106− field.label()
107− ));
105+ if !write.changes.is_empty() {
106+ let mut file = lofty::read_from_path(&write.path).map_err(|e| describe(&e))?;
107+ if file.primary_tag().is_none() {
108+ file.insert_tag(Tag::new(file.primary_tag_type()));
109+ }
110+ let tag = file
111+ .primary_tag_mut()
112+ .ok_or_else(|| format!("{}: cannot create a tag", write.uri))?;
113+ for (field, value) in &write.changes {
114+ let value = value.trim();
115+ if value.is_empty() {
116+ tag.remove_key(field.item_key());
117+ } else if !tag.insert_text(field.item_key(), value.to_owned()) {
118+ return Err(format!(
119+ "{}: the {:?} tag cannot hold {}",
120+ write.uri,
121+ tag.tag_type(),
122+ field.label()
123+ ));
124+ }
108125 }
126+ tag.save_to_path(&write.path, WriteOptions::default())
127+ .map_err(|e| describe(&e))?;
128+ }
129+ if let Some(name) = &write.rename {
130+ let target = write.path.with_file_name(name.trim());
131+ if target.exists() {
132+ return Err(format!("{}: “{}” already exists", write.uri, name.trim()));
133+ }
134+ std::fs::rename(&write.path, &target).map_err(|e| describe(&e))?;
135+ }
136+ Ok(())
137+}
138+
139+/// `du hast (live)` → `Du Hast (Live)`.
140+pub fn capitalize_words(value: &str) -> String {
141+ let mut out = String::with_capacity(value.len());
142+ let mut at_word_start = true;
143+ for ch in value.chars() {
144+ if at_word_start {
145+ out.extend(ch.to_uppercase());
146+ } else {
147+ out.push(ch);
148+ }
149+ at_word_start = ch.is_whitespace() || matches!(ch, '(' | '[' | '-' | '/' | '"');
150+ }
151+ out
152+}
153+
154+#[cfg(test)]
155+mod tests {
156+ use super::*;
157+
158+ #[test]
159+ fn capitalizes_first_letters() {
160+ assert_eq!(
161+ capitalize_words("du hast (live) - one"),
162+ "Du Hast (Live) - One"
163+ );
164+ assert_eq!(capitalize_words("été wORLD"), "Été WORLD");
165+ }
166+
167+ #[test]
168+ fn rejects_bad_renames() {
169+ let rename = |name: &str| TagWrite {
170+ path: "/nonexistent/a.mp3".into(),
171+ uri: "a.mp3".into(),
172+ rename: Some(name.into()),
173+ ..TagWrite::default()
174+ };
175+ assert!(
176+ write(&rename("x/y.mp3"))
177+ .unwrap_err()
178+ .contains("invalid file name")
179+ );
180+ assert!(
181+ write(&rename(".."))
182+ .unwrap_err()
183+ .contains("invalid file name")
184+ );
109185 }
110− tag.save_to_path(&write.path, WriteOptions::default())
111− .map_err(|e| describe(&e))
112186 }
M src/ui/input.rs View
@@ -121,7 +121,7 @@ impl LineInput {
121121 width += w;
122122 }
123123 let line = Line::from(vec![
124− Span::styled(prefix.to_owned(), theme.accent()),
124+ Span::styled(prefix.to_owned(), theme.header()),
125125 Span::styled(visible, theme.text()),
126126 ]);
127127 frame.render_widget(line, area);
M src/ui/list.rs View
@@ -9,12 +9,13 @@ use nucleo_matcher::{
99 };
1010 use ratatui::{
1111 Frame,
12− layout::{Constraint, Position, Rect},
12+ layout::{Constraint, Layout, Position, Rect},
1313 style::{Modifier, Style},
14− widgets::{Block, Row, Table, TableState},
14+ text::Line,
15+ widgets::{Row, Table, TableState},
1516 };
1617
17−use crate::{action::Action, config::Theme};
18+use crate::{action::Action, config::Theme, ui};
1819
1920 /// Cursor, scroll offset and search state of a list.
2021 ///
@@ -137,28 +138,42 @@ impl ListView {
137138 }
138139 }
139140
140− /// Draws rows `offset..offset + height` inside `block`.
141+ /// Draws an optional header with a `─` line below it, then rows
142+ /// `offset..offset + height`.
141143 #[allow(clippy::too_many_arguments)]
142144 pub fn render<'a>(
143145 &mut self,
144146 frame: &mut Frame,
145147 area: Rect,
146− block: Block<'a>,
147− header: Option<Row<'a>>,
148+ header: Option<Header<'a>>,
148149 widths: &[Constraint],
149150 len: usize,
150151 focused: bool,
151152 theme: &Theme,
152153 mut row: impl FnMut(usize) -> Row<'a>,
153154 ) {
154− let inner = block.inner(area);
155− let header_height = u16::from(header.is_some());
156− self.rows_area = Rect {
157− y: inner.y + header_height,
158− height: inner.height.saturating_sub(header_height),
159− ..inner
160− };
161− self.height = usize::from(self.rows_area.height);
155+ let mut rows_area = area;
156+ if let Some(header) = header {
157+ let [header_area, line_area, rest] = Layout::vertical([
158+ Constraint::Length(1),
159+ Constraint::Length(1),
160+ Constraint::Fill(1),
161+ ])
162+ .areas(area);
163+ match header {
164+ Header::Title(title) => {
165+ frame.render_widget(title.style(theme.header()), header_area)
166+ }
167+ Header::Columns(columns) => frame.render_widget(
168+ Table::new([columns.style(theme.header())], widths.to_vec()).column_spacing(1),
169+ header_area,
170+ ),
171+ }
172+ ui::draw_hline(frame, line_area, theme);
173+ rows_area = rest;
174+ }
175+ self.rows_area = rows_area;
176+ self.height = usize::from(rows_area.height);
162177 self.selected = self.selected.min(len.saturating_sub(1));
163178 if self.selected < self.offset {
164179 self.offset = self.selected;
@@ -183,21 +198,25 @@ impl ListView {
183198 } else {
184199 theme.selection_inactive()
185200 };
186− let mut table = Table::new(rows, widths.to_vec())
187− .block(block)
201+ let table = Table::new(rows, widths.to_vec())
188202 .column_spacing(1)
189203 .row_highlight_style(highlight);
190− if let Some(header) = header {
191− table = table.header(header);
192− }
193204 let mut state = TableState::default();
194205 if len > 0 {
195206 state.select(Some(self.selected - self.offset));
196207 }
197− frame.render_stateful_widget(table, area, &mut state);
208+ frame.render_stateful_widget(table, rows_area, &mut state);
198209 }
199210 }
200211
212+/// What is drawn above a list.
213+pub enum Header<'a> {
214+ /// A column title, e.g. `Directories`.
215+ Title(Line<'a>),
216+ /// Table column names.
217+ Columns(Row<'a>),
218+}
219+
201220 /// Fuzzy search results over a list, kept in list order so that `n`/`N`
202221 /// walk through them like in a pager.
203222 #[derive(Debug, Default)]
M src/ui/mod.rs View
@@ -1,4 +1,12 @@
1−//! Widgets and the parts of the frame shared by all screens.
1+//! The ncmpcpp-style frame shared by all screens, plus common widgets.
2+//!
3+//! ```text
4+//! Title of the screen Volume: 100%
5+//! ──────────────────────────────────────────────────────────────────[rz]─
6+//! (screen)
7+//! ━━━━━━━━━━━━━━━━━━━━━
8+//! Playing: "Title" by Artist in Album (Year) (320 kbps) [1:41/3:56]
9+//! ```
210
311 pub mod input;
412 pub mod list;
@@ -7,73 +15,25 @@ use std::time::Duration;
715
816 use ratatui::{
917 Frame,
10− layout::{Alignment, Constraint, Layout, Rect},
18+ layout::{Constraint, Layout, Rect},
1119 style::{Modifier, Style},
20+ symbols,
1221 text::{Line, Span},
13− widgets::{Block, BorderType, Paragraph},
22+ widgets::{Cell, Paragraph, Row},
1423 };
1524 use unicode_width::UnicodeWidthStr;
1625
1726 use crate::{
18− action::ScreenId,
19− config::{ColumnField, Theme},
27+ config::{Column, ColumnField, Theme},
2028 mpd::{PlayState, Song},
2129 state::MpdState,
2230 util::format_duration,
2331 };
2432
25−/// A bordered pane; focused panes get the accent color.
26−pub fn pane<'a>(title: impl Into<Line<'a>>, focused: bool, theme: &Theme) -> Block<'a> {
27− let style = if focused {
28− theme.accent()
29− } else {
30− theme.border()
31− };
32− Block::bordered()
33− .border_type(BorderType::Rounded)
34− .border_style(style)
35− .title(title.into().style(style))
36−}
37−
38−/// The text of one song column.
39−pub fn song_field(song: &Song, field: ColumnField) -> String {
40− let text = match field {
41− ColumnField::Artist => song.artist(),
42− ColumnField::AlbumArtist => song.album_artist(),
43− ColumnField::Title => Some(song.display_title()),
44− ColumnField::Album => song.album(),
45− ColumnField::Track => song.track(),
46− ColumnField::Disc => song.tag("Disc"),
47− ColumnField::Date => song.tag("Date"),
48− ColumnField::Genre => song.tag("Genre"),
49− ColumnField::Duration => return song.duration.map(format_duration).unwrap_or_default(),
50− ColumnField::File => Some(song.file.as_str()),
51− };
52− text.unwrap_or_default().to_owned()
53−}
54−
55−/// `Artist - Title`, or just the title.
56−pub fn song_label(song: &Song) -> String {
57− match song.artist() {
58− Some(artist) => format!("{artist} - {}", song.display_title()),
59− None => song.display_title().to_owned(),
60− }
61−}
62−
63−/// The text a song is searched by.
64−pub fn song_search_text(song: &Song) -> String {
65− format!(
66− "{} {} {}",
67− song.artist().unwrap_or_default(),
68− song.display_title(),
69− song.album().unwrap_or_default()
70− )
71−}
72−
7333 /// The rows of the frame around the active screen.
7434 pub struct FrameLayout {
75− pub header: Rect,
76− pub tabs: Rect,
35+ pub title: Rect,
36+ pub separator: Rect,
7737 pub body: Rect,
7838 pub progress: Rect,
7939 pub status: Rect,
@@ -81,8 +41,8 @@ pub struct FrameLayout {
8141
8242 impl FrameLayout {
8343 pub fn new(area: Rect) -> Self {
84− let [header, tabs, body, progress, status] = Layout::vertical([
85− Constraint::Length(2),
44+ let [title, separator, body, progress, status] = Layout::vertical([
45+ Constraint::Length(1),
8646 Constraint::Length(1),
8747 Constraint::Fill(1),
8848 Constraint::Length(1),
@@ -90,8 +50,8 @@ impl FrameLayout {
9050 ])
9151 .areas(area);
9252 Self {
93− header,
94− tabs,
53+ title,
54+ separator,
9555 body,
9656 progress,
9757 status,
@@ -99,230 +59,279 @@ impl FrameLayout {
9959 }
10060 }
10161
102−pub fn draw_header(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) {
103− let [top, bottom] = Layout::vertical([Constraint::Length(1); 2]).areas(area);
104− let status = &mpd.status;
105−
106− let (icon, title, details) = match (&mpd.current, mpd.connected) {
107− (_, false) => ("✕", "Not connected".to_owned(), String::new()),
108− (Some(song), true) => {
109− let icon = match status.state {
110− PlayState::Play => "▶",
111− PlayState::Pause => "⏸",
112− PlayState::Stop => "■",
113− };
114− let mut details = song.artist().unwrap_or("Unknown artist").to_owned();
115− if let Some(album) = song.album() {
116− details.push_str(" — ");
117− details.push_str(album);
118− if let Some(date) = song.tag("Date") {
119− details.push_str(&format!(" ({date})"));
120− }
121− }
122− (icon, song.display_title().to_owned(), details)
123− }
124− (None, true) => ("■", "Stopped".to_owned(), String::new()),
62+/// Draws the title row and the line below it.
63+pub fn draw_title(
64+ frame: &mut Frame,
65+ layout: &FrameLayout,
66+ title: Line,
67+ mpd: &MpdState,
68+ theme: &Theme,
69+) {
70+ let volume = match mpd.status.volume {
71+ _ if !mpd.connected => "Not connected".to_owned(),
72+ Some(0) => "Volume: muted".to_owned(),
73+ Some(v) => format!("Volume: {v}%"),
74+ None => "Volume: n/a".to_owned(),
12575 };
76+ let [left, right] = Layout::horizontal([
77+ Constraint::Fill(1),
78+ Constraint::Length(volume.width() as u16),
79+ ])
80+ .areas(layout.title);
81+ frame.render_widget(title.style(theme.header()), left);
82+ frame.render_widget(Span::styled(volume, theme.dim()), right);
12683
127− let flag = |on: bool, letter: &'static str| {
128− if on {
129− Span::styled(letter, theme.accent().add_modifier(Modifier::BOLD))
130− } else {
131− Span::styled(letter, theme.dim())
132− }
133− };
134− let volume = status
135− .volume
136− .map_or_else(|| "vol n/a".to_owned(), |v| format!("vol {v:>3}%"));
137− let right = Line::from(vec![
138− Span::styled(volume, theme.text()),
139− Span::styled(" [", theme.dim()),
140− flag(status.repeat, "r"),
141− flag(status.random, "z"),
142− flag(status.single.is_on(), "s"),
143− flag(status.consume.is_on(), "c"),
144− Span::styled("]", theme.dim()),
145− ]);
146− let mut audio = Vec::new();
147− if status.updating_db {
148− audio.push("updating database".to_owned());
149− }
150− if let Some(bitrate) = status.bitrate {
151− audio.push(format!("{bitrate} kbps"));
152− }
153− if let Some(format) = status.audio.as_deref() {
154− audio.push(format_audio(format));
84+ // Active modes sit on the line, ncmpcpp style: ───[rz]─
85+ let status = &mpd.status;
86+ let flags: String = [
87+ (status.repeat, 'r'),
88+ (status.random, 'z'),
89+ (status.single.is_on(), 's'),
90+ (status.consume.is_on(), 'c'),
91+ (status.updating_db, 'U'),
92+ ]
93+ .iter()
94+ .filter(|(on, _)| *on)
95+ .map(|(_, c)| *c)
96+ .collect();
97+ draw_hline(frame, layout.separator, theme);
98+ if !flags.is_empty() {
99+ let text = format!("[{flags}]");
100+ let width = text.width() as u16;
101+ let x = layout.separator.right().saturating_sub(width + 1);
102+ let area = Rect::new(x, layout.separator.y, width, 1);
103+ frame.render_widget(
104+ Span::styled(text, theme.text().add_modifier(Modifier::BOLD)),
105+ area,
106+ );
155107 }
156− let right_width = right.width() as u16;
157−
158− let [title_area, flags_area] =
159− Layout::horizontal([Constraint::Fill(1), Constraint::Length(right_width + 1)]).areas(top);
160− frame.render_widget(
161− Line::from(vec![
162− Span::styled(format!(" {icon} "), theme.accent()),
163− Span::styled(title, theme.text().add_modifier(Modifier::BOLD)),
164− ]),
165− title_area,
166− );
167− frame.render_widget(right.alignment(Alignment::Right), flags_area);
108+}
168109
169− let audio = audio.join(" · ");
170− let [details_area, audio_area] = Layout::horizontal([
171− Constraint::Fill(1),
172− Constraint::Length(audio.width() as u16 + 1),
173− ])
174− .areas(bottom);
175− frame.render_widget(
176− Line::from(Span::styled(format!(" {details}"), theme.dim())),
177− details_area,
178− );
110+/// A horizontal `─` line across `area`.
111+pub fn draw_hline(frame: &mut Frame, area: Rect, theme: &Theme) {
179112 frame.render_widget(
180− Line::from(Span::styled(audio, theme.dim())).alignment(Alignment::Right),
181− audio_area,
113+ Span::styled(
114+ symbols::line::HORIZONTAL.repeat(usize::from(area.width)),
115+ theme.border(),
116+ ),
117+ area,
182118 );
183119 }
184120
185−/// `44100:16:2` → `44.1 kHz · 16 bit · stereo`.
186−fn format_audio(format: &str) -> String {
187− let mut parts = format.split(':');
188− let (Some(rate), Some(bits), Some(channels)) = (parts.next(), parts.next(), parts.next())
189− else {
190− return format.to_owned();
191− };
192− let rate = rate
193− .parse::<f64>()
194− .map_or_else(|_| rate.to_owned(), |r| format!("{} kHz", r / 1000.0));
195− let bits = match bits {
196− "f" => "float".to_owned(),
197− "dsd" => "DSD".to_owned(),
198− bits => format!("{bits} bit"),
199− };
200− let channels = match channels {
201− "1" => "mono".to_owned(),
202− "2" => "stereo".to_owned(),
203− n => format!("{n} ch"),
204− };
205− format!("{rate} · {bits} · {channels}")
206−}
207−
208−/// Draws the tab bar and returns each tab's horizontal span for mouse hits.
209−pub fn draw_tabs(
121+/// Splits `area` into columns separated by `│` lines and draws the lines.
122+pub fn split_columns(
210123 frame: &mut Frame,
211124 area: Rect,
212− active: ScreenId,
125+ constraints: &[Constraint],
213126 theme: &Theme,
214−) -> Vec<(u16, u16, ScreenId)> {
215− let mut spans = vec![Span::raw(" ")];
216− let mut hits = Vec::new();
217− let mut x = area.x + 1;
218− for (i, &screen) in ScreenId::ALL.iter().enumerate() {
219− let label = format!(" {} {} ", i + 1, screen.title());
220− let width = label.width() as u16;
221− let style = if screen == active {
222− theme.selection().add_modifier(Modifier::BOLD)
127+) -> Vec<Rect> {
128+ let mut with_separators = Vec::with_capacity(constraints.len() * 2);
129+ for (i, constraint) in constraints.iter().enumerate() {
130+ if i > 0 {
131+ with_separators.push(Constraint::Length(1));
132+ }
133+ with_separators.push(*constraint);
134+ }
135+ let areas = Layout::horizontal(with_separators).split(area);
136+ let mut columns = Vec::with_capacity(constraints.len());
137+ for (i, rect) in areas.iter().enumerate() {
138+ if i % 2 == 1 {
139+ let line: Vec<Line> = (0..rect.height)
140+ .map(|_| Line::styled(symbols::line::VERTICAL, theme.border()))
141+ .collect();
142+ frame.render_widget(Paragraph::new(line), *rect);
223143 } else {
224− theme.dim()
144+ columns.push(*rect);
145+ }
146+ }
147+ columns
148+}
149+
150+/// Column widths of a song table.
151+pub fn song_widths(columns: &[Column]) -> Vec<Constraint> {
152+ columns
153+ .iter()
154+ .map(|c| match c.field {
155+ ColumnField::Duration | ColumnField::Track | ColumnField::Disc => {
156+ Constraint::Length(c.width)
157+ }
158+ _ => Constraint::Fill(c.width),
159+ })
160+ .collect()
161+}
162+
163+/// The header row of a song table.
164+pub fn song_header(columns: &[Column]) -> Row<'static> {
165+ Row::new(columns.iter().map(|c| {
166+ let title = c.field.title();
167+ match c.field {
168+ ColumnField::Duration => Cell::from(Line::from(title).right_aligned()),
169+ _ => Cell::from(title),
170+ }
171+ }))
172+}
173+
174+/// One song as a table row. The playing song is prefixed with `> `.
175+pub fn song_row(song: &Song, columns: &[Column], playing: bool, theme: &Theme) -> Row<'static> {
176+ let cells = columns.iter().enumerate().map(|(i, c)| {
177+ let mut text = song_field(song, c.field);
178+ if playing && i == 0 {
179+ text.insert_str(0, "> ");
180+ }
181+ let style = match c.field {
182+ ColumnField::Artist | ColumnField::AlbumArtist => Style::new().fg(theme.artist),
183+ ColumnField::Track | ColumnField::Disc => Style::new().fg(theme.track),
184+ ColumnField::Album => Style::new().fg(theme.album),
185+ ColumnField::Duration => Style::new().fg(theme.duration),
186+ _ => theme.text(),
225187 };
226− hits.push((x, x + width, screen));
227− x += width + 1;
228− spans.push(Span::styled(label, style));
229− spans.push(Span::raw(" "));
188+ match c.field {
189+ ColumnField::Duration => Cell::from(Line::styled(text, style).right_aligned()),
190+ // Track numbers are padded like ncmpcpp: ` 1` → `01`.
191+ ColumnField::Track | ColumnField::Disc if !text.is_empty() => {
192+ Cell::from(Span::styled(format!("{text:0>2}"), style))
193+ }
194+ _ => Cell::from(Span::styled(text, style)),
195+ }
196+ });
197+ let row = Row::new(cells);
198+ if playing {
199+ row.style(theme.playing())
200+ } else {
201+ row
230202 }
231− frame.render_widget(Line::from(spans), area);
232− hits
233203 }
234204
235−/// Draws `elapsed ━━━━──── total`.
205+/// The text of one song column.
206+pub fn song_field(song: &Song, field: ColumnField) -> String {
207+ let text = match field {
208+ ColumnField::Artist => song.artist(),
209+ ColumnField::AlbumArtist => song.album_artist(),
210+ ColumnField::Title => Some(song.display_title()),
211+ ColumnField::Album => song.album(),
212+ ColumnField::Track => song.track(),
213+ ColumnField::Disc => song.tag("Disc"),
214+ ColumnField::Date => song.tag("Date"),
215+ ColumnField::Genre => song.tag("Genre"),
216+ ColumnField::Duration => return song.duration.map(format_duration).unwrap_or_default(),
217+ ColumnField::File => Some(song.file.as_str()),
218+ };
219+ clean(text.unwrap_or_default())
220+}
221+
222+/// Removes zero-width characters (variation selectors, joiners) for display.
223+/// Terminals and ratatui disagree on their width, which leaves stray
224+/// characters on screen.
225+pub fn clean(text: &str) -> String {
226+ text.chars()
227+ .filter(|c| !matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{200B}'..='\u{200D}' | '\u{2060}'))
228+ .collect()
229+}
230+
231+/// `Artist - Title`, or just the title.
232+pub fn song_label(song: &Song) -> String {
233+ clean(&match song.artist() {
234+ Some(artist) => format!("{artist} - {}", song.display_title()),
235+ None => song.display_title().to_owned(),
236+ })
237+}
238+
239+/// The text a song is searched by.
240+pub fn song_search_text(song: &Song) -> String {
241+ format!(
242+ "{} {} {}",
243+ song.artist().unwrap_or_default(),
244+ song.display_title(),
245+ song.album().unwrap_or_default()
246+ )
247+}
248+
249+/// Draws the thin progress bar.
236250 pub fn draw_progress(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) {
237− let elapsed = mpd.elapsed();
238251 let total = mpd.status.duration.unwrap_or_default();
239− let left = format!(" {} ", format_duration(elapsed));
240− let right = format!(" {} ", format_duration(total));
241− let [left_area, bar, right_area] = progress_layout(area, &left, &right);
242− frame.render_widget(Span::styled(left, theme.dim()), left_area);
243− frame.render_widget(Span::styled(right, theme.dim()), right_area);
244−
245− let width = usize::from(bar.width);
252+ let width = usize::from(area.width);
246253 let filled = if total.is_zero() || mpd.status.state == PlayState::Stop {
247254 0
248255 } else {
249− ((elapsed.as_secs_f64() / total.as_secs_f64()) * width as f64).round() as usize
256+ ((mpd.elapsed().as_secs_f64() / total.as_secs_f64()) * width as f64).round() as usize
250257 }
251258 .min(width);
252259 frame.render_widget(
253− Line::from(vec![
254− Span::styled("━".repeat(filled), theme.accent()),
255− Span::styled("─".repeat(width - filled), theme.border()),
256− ]),
257− bar,
260+ Span::styled(
261+ symbols::line::THICK_HORIZONTAL.repeat(filled),
262+ Style::new().fg(theme.progress_filled),
263+ ),
264+ area,
258265 );
259266 }
260267
261−fn progress_layout(area: Rect, left: &str, right: &str) -> [Rect; 3] {
262− Layout::horizontal([
263− Constraint::Length(left.width() as u16),
264− Constraint::Fill(1),
265− Constraint::Length(right.width() as u16),
266− ])
267− .areas(area)
268−}
269−
270268 /// Maps a click on the progress bar to a position in the song.
271269 pub fn progress_seek_target(area: Rect, x: u16, mpd: &MpdState) -> Option<Duration> {
272270 let total = mpd.status.duration?;
273− let left = format!(" {} ", format_duration(mpd.elapsed()));
274− let right = format!(" {} ", format_duration(total));
275− let [_, bar, _] = progress_layout(area, &left, &right);
276− if bar.width == 0 || x < bar.x || x >= bar.right() {
271+ if area.width == 0 || x < area.x || x >= area.right() {
277272 return None;
278273 }
279− let fraction = f64::from(x - bar.x) / f64::from(bar.width);
280− Some(total.mul_f64(fraction))
274+ Some(total.mul_f64(f64::from(x - area.x) / f64::from(area.width)))
275+}
276+
277+/// `Playing: "Title" by Artist in Album (Year)` and `(320 kbps) [1:41/3:56]`.
278+pub fn draw_now_playing(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) {
279+ let status = &mpd.status;
280+ let (Some(song), PlayState::Play | PlayState::Pause) = (&mpd.current, status.state) else {
281+ return;
282+ };
283+ let mut right = String::new();
284+ if let Some(bitrate) = status.bitrate {
285+ right.push_str(&format!("({bitrate} kbps) "));
286+ }
287+ right.push_str(&format!(
288+ "[{}/{}]",
289+ format_duration(mpd.elapsed()),
290+ format_duration(status.duration.unwrap_or_default())
291+ ));
292+ let [left_area, right_area] = Layout::horizontal([
293+ Constraint::Fill(1),
294+ Constraint::Length(right.width() as u16),
295+ ])
296+ .areas(area);
297+
298+ let state = if status.state == PlayState::Play {
299+ "Playing: "
300+ } else {
301+ "Paused: "
302+ };
303+ let bold = theme.text().add_modifier(Modifier::BOLD);
304+ let mut spans = vec![
305+ Span::styled(state, theme.dim()),
306+ Span::styled(format!("\"{}\"", clean(song.display_title())), bold),
307+ ];
308+ if let Some(artist) = song.artist() {
309+ spans.push(Span::styled(" by ", theme.dim()));
310+ spans.push(Span::styled(clean(artist), theme.text()));
311+ }
312+ if let Some(album) = song.album() {
313+ spans.push(Span::styled(" in ", theme.dim()));
314+ let album = match song.tag("Date") {
315+ Some(date) => format!("{album} ({})", date.chars().take(4).collect::<String>()),
316+ None => album.to_owned(),
317+ };
318+ spans.push(Span::styled(clean(&album), theme.text()));
319+ }
320+ frame.render_widget(Line::from(spans), left_area);
321+ frame.render_widget(Span::styled(right, theme.text()), right_area);
281322 }
282323
283324 /// A one-line message, styled as an error if needed.
284325 pub fn draw_message(frame: &mut Frame, area: Rect, text: &str, error: bool, theme: &Theme) {
285326 let style = if error { theme.error() } else { theme.text() };
286− frame.render_widget(
287− Paragraph::new(Line::from(Span::styled(format!(" {text}"), style))),
288− area,
289− );
290−}
291−
292−/// Key hints shown in the status line when nothing else is.
293−pub fn draw_hint(frame: &mut Frame, area: Rect, text: &str, theme: &Theme) {
294− frame.render_widget(
295− Line::from(Span::styled(format!(" {text}"), theme.dim())).alignment(Alignment::Left),
296− area,
297− );
327+ frame.render_widget(Span::styled(text.to_owned(), style), area);
298328 }
299329
300330 /// A centered, dimmed placeholder for empty panes.
301331 pub fn draw_placeholder(frame: &mut Frame, area: Rect, lines: &[&str], theme: &Theme) {
302332 let top = area.height.saturating_sub(lines.len() as u16) / 2;
303333 let text: Vec<Line> = std::iter::repeat_n(Line::default(), usize::from(top))
304− .chain(
305− lines
306− .iter()
307− .map(|l| Line::from(Span::styled(*l, theme.dim()))),
308− )
334+ .chain(lines.iter().map(|l| Line::styled(*l, theme.dim())))
309335 .collect();
310− frame.render_widget(
311− Paragraph::new(text)
312− .alignment(Alignment::Center)
313− .style(Style::new()),
314− area,
315− );
316−}
317−
318−#[cfg(test)]
319−mod tests {
320− use super::*;
321−
322− #[test]
323− fn formats_audio() {
324− assert_eq!(format_audio("44100:16:2"), "44.1 kHz · 16 bit · stereo");
325− assert_eq!(format_audio("96000:f:1"), "96 kHz · float · mono");
326− assert_eq!(format_audio("weird"), "weird");
327− }
336+ frame.render_widget(Paragraph::new(text).centered(), area);
328337 }
M src/util.rs View
@@ -35,10 +35,47 @@ pub fn format_duration(duration: Duration) -> String {
3535 }
3636 }
3737
38+/// Spells out a duration like ncmpcpp: `1 hour, 3 minutes, 56 seconds`.
39+pub fn format_length(duration: Duration) -> String {
40+ let secs = duration.as_secs();
41+ let parts = [
42+ (secs / 86_400, "day"),
43+ (secs / 3600 % 24, "hour"),
44+ (secs / 60 % 60, "minute"),
45+ (secs % 60, "second"),
46+ ];
47+ let words: Vec<String> = parts
48+ .iter()
49+ .filter(|(n, _)| *n > 0)
50+ .map(|(n, unit)| format!("{n} {unit}{}", if *n == 1 { "" } else { "s" }))
51+ .collect();
52+ if words.is_empty() {
53+ "0 seconds".to_owned()
54+ } else {
55+ words.join(", ")
56+ }
57+}
58+
59+/// `1 item` / `3 items`.
60+pub fn plural(count: usize, noun: &str) -> String {
61+ format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
62+}
63+
3864 #[cfg(test)]
3965 mod tests {
4066 use super::*;
4167
68+ #[test]
69+ fn formats_lengths() {
70+ assert_eq!(
71+ format_length(Duration::from_secs(236)),
72+ "3 minutes, 56 seconds"
73+ );
74+ assert_eq!(format_length(Duration::from_secs(3601)), "1 hour, 1 second");
75+ assert_eq!(format_length(Duration::ZERO), "0 seconds");
76+ assert_eq!(plural(1, "item"), "1 item");
77+ }
78+
4279 #[test]
4380 fn formats_durations() {
4481 assert_eq!(format_duration(Duration::from_secs(5)), "0:05");