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.
+1354 −972
28 files changed
- M .github/copilot-instructions.md +1 −1
- M CHANGELOG.md +8 −0
- M README.md +40 −21
- M src/action.rs +2 −2
- M src/app.rs +27 −30
- M src/config/default.toml +29 −16
- M src/config/mod.rs +8 −5
- A src/config/mpd_conf.rs +87 −0
- M src/config/theme.rs +26 −13
- M src/mpd/connection.rs +17 −0
- M src/popups/add_to_playlist.rs +3 −4
- M src/popups/confirm.rs +2 −2
- M src/popups/help.rs +2 −3
- M src/popups/input.rs +5 −0
- M src/popups/mod.rs +3 −3
- M src/screens/browser.rs +40 −49
- M src/screens/library.rs +82 −99
- M src/screens/lyrics.rs +23 −17
- M src/screens/mod.rs +5 −1
- M src/screens/playlists.rs +27 −79
- M src/screens/queue.rs +36 −82
- M src/screens/tag_editor.rs +434 −234
- M src/screens/visualizer.rs +6 −9
- M src/tags.rs +114 −40
- M src/ui/input.rs +1 −1
- M src/ui/list.rs +39 −20
- M src/ui/mod.rs +250 −241
- M src/util.rs +37 −0
| @@ -34,7 +34,7 @@ | |||
| 34 | 34 | - Every screen sees every `MpdEvent`, so caches stay valid while hidden. | |
| 35 | 35 | - `popups/` holds modal components (`Popup` trait); the topmost popup gets all | |
| 36 | 36 | 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 | |
| 38 | 38 | virtualized searchable `ListView`, and the `LineInput` editor. | |
| 39 | 39 | - `config/`: | |
| 40 | 40 | - `default.toml` is embedded and is the single source of truth for | |
| @@ -16,6 +16,14 @@ A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc. | |||
| 16 | 16 | columns and lyrics/visualizer settings. The documented default file is the | |
| 17 | 17 | single source of truth; partial files keep all other defaults. 0.1 config | |
| 18 | 18 | 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. | |
| 19 | 27 | - New `--config` and `--print-default-config` options; `RMPTUI_LOG` sets | |
| 20 | 28 | the log level. | |
| 21 | 29 | ||
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | **A fast, keyboard-driven MPD client for your terminal.** | |
| 7 | 7 | ||
| 8 | 8 | 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 | |
| 10 | 10 | library, manage the queue and playlists, edit tags, read synced lyrics and | |
| 11 | 11 | watch a spectrum visualizer without leaving the terminal. | |
| 12 | 12 | ||
| @@ -16,14 +16,19 @@ watch a spectrum visualizer without leaving the terminal. | |||
| 16 | 16 | ||
| 17 | 17 | | Key | Screen | What it does | | |
| 18 | 18 | | --- | --- | --- | | |
| 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. | | |
| 25 | 25 | | `7` | **Lyrics** | Synced lyrics that follow playback, or plain lyrics you can scroll. | | |
| 26 | 26 | ||
| 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 | + | ||
| 27 | 32 | Other highlights: | |
| 28 | 33 | ||
| 29 | 34 | - **Instant controls.** All MPD traffic runs in background tasks over MPD's | |
| @@ -34,7 +39,7 @@ Other highlights: | |||
| 34 | 39 | - **Marks.** `v` marks items; queue removal, add-to-playlist and tag edits | |
| 35 | 40 | then apply to every marked item. | |
| 36 | 41 | - **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. | |
| 38 | 43 | - **TCP or Unix socket**, with password support and automatic reconnection. | |
| 39 | 44 | ||
| 40 | 45 | ## Requirements | |
| @@ -95,7 +100,7 @@ The main sections: | |||
| 95 | 100 | host = "127.0.0.1" # or a Unix socket path such as "~/.config/mpd/socket" | |
| 96 | 101 | port = 6600 | |
| 97 | 102 | 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) | |
| 99 | 104 | ||
| 100 | 105 | [playback] | |
| 101 | 106 | seek_seconds = 5 | |
| @@ -104,17 +109,21 @@ volume_step = 5 | |||
| 104 | 109 | [ui] | |
| 105 | 110 | startup_screen = "queue" | |
| 106 | 111 | mouse = true | |
| 112 | + | confirm_clear_queue = false | |
| 107 | 113 | lyrics_pane = false | |
| 108 | − | queue_columns = [ | |
| 109 | − | { field = "artist", width = 25 }, | |
| 114 | + | song_columns = [ # queue, browser and playlist editor | |
| 115 | + | { field = "artist", width = 34 }, | |
| 110 | 116 | { field = "track", width = 5 }, | |
| 111 | − | { field = "title", width = 35 }, | |
| 117 | + | { field = "title", width = 33 }, | |
| 112 | 118 | { field = "album", width = 30 }, | |
| 113 | − | { field = "duration", width = 7 }, | |
| 119 | + | { field = "duration", width = 5 }, | |
| 114 | 120 | ] | |
| 115 | 121 | ||
| 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" | |
| 118 | 127 | visualizer = ["blue", "cyan", "green", "yellow", "red"] | |
| 119 | 128 | ||
| 120 | 129 | [lyrics] | |
| @@ -173,10 +182,20 @@ the lyrics of the current song again. | |||
| 173 | 182 | ||
| 174 | 183 | ### Tag editor | |
| 175 | 184 | ||
| 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. | |
| 180 | 199 | ||
| 181 | 200 | ## Key bindings | |
| 182 | 201 | ||
| @@ -201,6 +220,7 @@ Playlists screen and toggles consume elsewhere). | |||
| 201 | 220 | | `+` `=` / `-` | Volume up / down | | |
| 202 | 221 | | `m` | Toggle mute | | |
| 203 | 222 | | `r` `z` `y` `R` | Toggle repeat, random, single, consume | | |
| 223 | + | | `c` | Clear the queue | | |
| 204 | 224 | | `U` | Rescan the music database | | |
| 205 | 225 | | `?` `F1` | Show key bindings | | |
| 206 | 226 | | `q` `Ctrl+c` | Quit | | |
| @@ -225,14 +245,13 @@ Playlists screen and toggles consume elsewhere). | |||
| 225 | 245 | | --- | --- | --- | | |
| 226 | 246 | | Queue | `d` `Delete` | Remove the song (or all marked songs) | | |
| 227 | 247 | | Queue | `J` / `K` | Move the song down / up | | |
| 228 | − | | Queue | `c` | Clear the queue (asks first) | | |
| 229 | 248 | | Queue | `Z` / `S` | Shuffle / save as playlist | | |
| 230 | 249 | | Queue | `o` | Jump to the playing song | | |
| 231 | 250 | | Queue | `L` | Toggle the lyrics pane | | |
| 232 | 251 | | Playlists | `d`, `J` / `K` | Remove / move the selected song | | |
| 233 | 252 | | Playlists | `R` / `D` | Rename / delete the playlist | | |
| 234 | 253 | | 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 | | |
| 236 | 255 | | Tags | `w` / `u` | Write / discard pending changes | | |
| 237 | 256 | | Lyrics | `F5` | Fetch lyrics again | | |
| 238 | 257 | ||
| @@ -210,12 +210,12 @@ impl Action { | |||
| 210 | 210 | | SeekBackward | VolumeUp | VolumeDown | ToggleMute | ToggleRepeat | ToggleRandom | |
| 211 | 211 | | ToggleSingle | ToggleConsume | UpdateDatabase | NextScreen | PreviousScreen | |
| 212 | 212 | | ScreenQueue | ScreenBrowser | ScreenPlaylists | ScreenLibrary | ScreenTagEditor | |
| 213 | − | | ScreenVisualizer | ScreenLyrics => &[C::Global], | |
| 213 | + | | ScreenVisualizer | ScreenLyrics | ClearQueue => &[C::Global], | |
| 214 | 214 | Up | Down | Top | Bottom | PageUp | PageDown | HalfPageUp | HalfPageDown | Left | |
| 215 | 215 | | Right | Activate | ToggleMark | ClearMarks | Search | SearchNext | SearchPrevious | |
| 216 | 216 | | AddToQueue | AddToPlaylist => &[C::Navigation], | |
| 217 | 217 | Delete | MoveItemUp | MoveItemDown => &[C::Queue, C::Playlists], | |
| 218 | − | ClearQueue | ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue], | |
| 218 | + | ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue], | |
| 219 | 219 | DeletePlaylist | RenamePlaylist => &[C::Playlists], | |
| 220 | 220 | ToggleArtistTag => &[C::Library], | |
| 221 | 221 | SaveTags | RevertTags => &[C::TagEditor], | |
| @@ -13,11 +13,11 @@ use tracing::info; | |||
| 13 | 13 | ||
| 14 | 14 | use crate::{ | |
| 15 | 15 | action::{Action, Context, ScreenId}, | |
| 16 | − | config::{Config, KeyCombo}, | |
| 16 | + | config::{self, Config, KeyCombo}, | |
| 17 | 17 | event::AppEvent, | |
| 18 | 18 | lyrics::LyricsService, | |
| 19 | 19 | mpd::{self, MpdEvent, MpdHandle, PlayState, Query, Reply, Subsystem, commands}, | |
| 20 | − | popups::{HelpPopup, Popup, PopupState}, | |
| 20 | + | popups::{ConfirmPopup, HelpPopup, Popup, PopupState}, | |
| 21 | 21 | screens::{Ctx, Effect, Screens}, | |
| 22 | 22 | state::MpdState, | |
| 23 | 23 | tags, | |
| @@ -46,7 +46,6 @@ pub struct App { | |||
| 46 | 46 | lyrics: LyricsService, | |
| 47 | 47 | visualizer: Visualizer, | |
| 48 | 48 | layout: Option<FrameLayout>, | |
| 49 | − | tab_hits: Vec<(u16, u16, ScreenId)>, | |
| 50 | 49 | /// The volume before muting. | |
| 51 | 50 | muted_volume: Option<u8>, | |
| 52 | 51 | queue_requested: bool, | |
| @@ -57,12 +56,17 @@ impl App { | |||
| 57 | 56 | pub fn new(config: Config) -> anyhow::Result<Self> { | |
| 58 | 57 | let (address, password) = config.mpd_connection()?; | |
| 59 | 58 | 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)); | |
| 60 | 64 | let (events_tx, events_rx) = mpsc::unbounded_channel(); | |
| 61 | 65 | let client = mpd::spawn(address, password, events_tx.clone()); | |
| 62 | 66 | let visualizer = Visualizer::spawn(config.visualizer.clone()); | |
| 63 | 67 | let screens = Screens::new(&config, visualizer.subscribe()); | |
| 64 | 68 | let mut mpd = MpdState::default(); | |
| 65 | − | mpd.music_dir = config.music_directory(); | |
| 69 | + | mpd.music_dir = music_dir; | |
| 66 | 70 | let mut app = Self { | |
| 67 | 71 | lyrics: LyricsService::new(config.lyrics.clone(), events_tx.clone()), | |
| 68 | 72 | active: config.ui.startup_screen, | |
| @@ -77,7 +81,6 @@ impl App { | |||
| 77 | 81 | message: None, | |
| 78 | 82 | visualizer, | |
| 79 | 83 | layout: None, | |
| 80 | − | tab_hits: Vec::new(), | |
| 81 | 84 | muted_volume: None, | |
| 82 | 85 | queue_requested: false, | |
| 83 | 86 | running: true, | |
| @@ -320,6 +323,18 @@ impl App { | |||
| 320 | 323 | Action::ToggleRandom => client.exec(commands::random(!status.random)), | |
| 321 | 324 | Action::ToggleSingle => client.exec(commands::single(!status.single.is_on())), | |
| 322 | 325 | 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 | + | } | |
| 323 | 338 | Action::UpdateDatabase => { | |
| 324 | 339 | client.exec(commands::update(None)); | |
| 325 | 340 | self.notify("Updating the database…".into(), false); | |
| @@ -346,16 +361,7 @@ impl App { | |||
| 346 | 361 | }; | |
| 347 | 362 | let position = Position::new(mouse.column, mouse.row); | |
| 348 | 363 | 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) { | |
| 359 | 365 | if left_click | |
| 360 | 366 | && self.mpd.status.state != PlayState::Stop | |
| 361 | 367 | && let Some(target) = | |
| @@ -442,7 +448,7 @@ impl App { | |||
| 442 | 448 | self.mpd.playlists.clone_from(playlists) | |
| 443 | 449 | } | |
| 444 | 450 | MpdEvent::Reply(Reply::MusicDirectory(dir)) => { | |
| 445 | − | if self.config.music_directory().is_none() { | |
| 451 | + | if self.config.music_directory().is_none() && dir.is_some() { | |
| 446 | 452 | self.mpd.music_dir.clone_from(dir); | |
| 447 | 453 | } | |
| 448 | 454 | } | |
| @@ -496,13 +502,10 @@ impl App { | |||
| 496 | 502 | fn draw(&mut self, frame: &mut Frame) { | |
| 497 | 503 | let layout = FrameLayout::new(frame.area()); | |
| 498 | 504 | 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 | − | ||
| 502 | 505 | 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); | |
| 506 | 509 | ui::draw_progress(frame, layout.progress, &self.mpd, theme); | |
| 507 | 510 | ||
| 508 | 511 | if self | |
| @@ -513,17 +516,11 @@ impl App { | |||
| 513 | 516 | self.message = None; | |
| 514 | 517 | } | |
| 515 | 518 | if let Some(search) = &self.search { | |
| 516 | − | search.render(frame, layout.status, " /", theme); | |
| 519 | + | search.render(frame, layout.status, "/", theme); | |
| 517 | 520 | } else if let Some(message) = &self.message { | |
| 518 | 521 | ui::draw_message(frame, layout.status, &message.text, message.error, theme); | |
| 519 | 522 | } 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); | |
| 527 | 524 | } | |
| 528 | 525 | for popup in &mut self.popups { | |
| 529 | 526 | popup.draw(frame, frame.area(), &ctx); | |
| @@ -29,39 +29,52 @@ volume_step = 5 | |||
| 29 | 29 | # Screen shown on startup: queue, browser, playlists, library, tag_editor, | |
| 30 | 30 | # visualizer or lyrics. | |
| 31 | 31 | 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). | |
| 34 | 34 | mouse = true | |
| 35 | + | # Ask before clearing the queue. | |
| 36 | + | confirm_clear_queue = false | |
| 35 | 37 | # Show the lyrics pane next to the queue on startup (toggle with `L`). | |
| 36 | 38 | lyrics_pane = false | |
| 37 | 39 | # Width of the lyrics pane in percent of the screen. | |
| 38 | 40 | lyrics_pane_width = 40 | |
| 39 | 41 | # Seconds a status message stays visible. | |
| 40 | 42 | 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 }, | |
| 45 | 49 | { field = "track", width = 5 }, | |
| 46 | − | { field = "title", width = 35 }, | |
| 50 | + | { field = "title", width = 33 }, | |
| 47 | 51 | { field = "album", width = 30 }, | |
| 48 | − | { field = "duration", width = 7 }, | |
| 52 | + | { field = "duration", width = 5 }, | |
| 49 | 53 | ] | |
| 50 | 54 | ||
| 51 | 55 | [theme] | |
| 52 | 56 | # Colors are names (black, red, green, yellow, blue, magenta, cyan, gray, | |
| 53 | 57 | # 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. | |
| 56 | 59 | text = "reset" | |
| 57 | 60 | 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" | |
| 61 | 68 | marked = "yellow" | |
| 62 | − | directory = "blue" | |
| 63 | − | border = "darkgray" | |
| 69 | + | directory = "reset" | |
| 64 | 70 | 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" | |
| 65 | 78 | # Visualizer bar gradient, from the bottom of the screen to the top. | |
| 66 | 79 | visualizer = ["blue", "cyan", "green", "yellow", "red"] | |
| 67 | 80 | ||
| @@ -123,6 +136,7 @@ toggle_random = ["z"] | |||
| 123 | 136 | toggle_single = ["y"] | |
| 124 | 137 | toggle_consume = ["R"] | |
| 125 | 138 | update_database = ["U"] | |
| 139 | + | clear_queue = ["c"] | |
| 126 | 140 | next_screen = ["Tab"] | |
| 127 | 141 | previous_screen = ["Shift+Tab"] | |
| 128 | 142 | screen_queue = ["1"] | |
| @@ -157,7 +171,6 @@ add_to_playlist = ["a"] | |||
| 157 | 171 | delete = ["d", "Delete"] | |
| 158 | 172 | move_item_up = ["K"] | |
| 159 | 173 | move_item_down = ["J"] | |
| 160 | − | clear_queue = ["c"] | |
| 161 | 174 | shuffle_queue = ["Z"] | |
| 162 | 175 | save_queue = ["S"] | |
| 163 | 176 | jump_to_current = ["o"] | |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | //! leave out. | |
| 6 | 6 | ||
| 7 | 7 | mod keys; | |
| 8 | + | mod mpd_conf; | |
| 8 | 9 | mod theme; | |
| 9 | 10 | ||
| 10 | 11 | use std::{ | |
| @@ -19,6 +20,7 @@ use std::{ | |||
| 19 | 20 | use serde::Deserialize; | |
| 20 | 21 | ||
| 21 | 22 | pub use keys::{Bindings, KeyCombo, Keymap}; | |
| 23 | + | pub use mpd_conf::local_music_directory; | |
| 22 | 24 | pub use theme::Theme; | |
| 23 | 25 | ||
| 24 | 26 | use crate::{action::ScreenId, mpd::Address, util::expand_tilde}; | |
| @@ -80,7 +82,7 @@ impl ColumnField { | |||
| 80 | 82 | Self::AlbumArtist => "Album Artist", | |
| 81 | 83 | Self::Title => "Title", | |
| 82 | 84 | Self::Album => "Album", | |
| 83 | − | Self::Track => "#", | |
| 85 | + | Self::Track => "Track", | |
| 84 | 86 | Self::Disc => "Disc", | |
| 85 | 87 | Self::Date => "Date", | |
| 86 | 88 | Self::Genre => "Genre", | |
| @@ -102,10 +104,11 @@ pub struct Column { | |||
| 102 | 104 | pub struct UiConfig { | |
| 103 | 105 | pub startup_screen: ScreenId, | |
| 104 | 106 | pub mouse: bool, | |
| 107 | + | pub confirm_clear_queue: bool, | |
| 105 | 108 | pub lyrics_pane: bool, | |
| 106 | 109 | pub lyrics_pane_width: u16, | |
| 107 | 110 | pub message_timeout: u64, | |
| 108 | − | pub queue_columns: Vec<Column>, | |
| 111 | + | pub song_columns: Vec<Column>, | |
| 109 | 112 | } | |
| 110 | 113 | ||
| 111 | 114 | #[derive(Clone, Debug, Deserialize)] | |
| @@ -246,8 +249,8 @@ impl Config { | |||
| 246 | 249 | "ui.lyrics_pane_width must be between 10 and 90", | |
| 247 | 250 | )?; | |
| 248 | 251 | 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", | |
| 251 | 254 | )?; | |
| 252 | 255 | let vis = &self.visualizer; | |
| 253 | 256 | check( | |
| @@ -447,7 +450,7 @@ mod tests { | |||
| 447 | 450 | "[mpd]\nport = 0", | |
| 448 | 451 | "[playback]\nvolume_step = 0", | |
| 449 | 452 | "[mpd]\nhots = \"typo\"", | |
| 450 | − | "[theme]\naccent = \"notacolor\"", | |
| 453 | + | "[theme]\ntext = \"notacolor\"", | |
| 451 | 454 | "[keys.global]\nquit = [\"Hyper+q\"]", | |
| 452 | 455 | "[keys.global]\nnot_an_action = [\"x\"]", | |
| 453 | 456 | // `p` is already bound to toggle_pause. | |
| @@ -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 | + | } | |
| @@ -7,13 +7,13 @@ use serde::{Deserialize, Deserializer, de}; | |||
| 7 | 7 | #[derive(Clone, Debug, Deserialize)] | |
| 8 | 8 | #[serde(deny_unknown_fields)] | |
| 9 | 9 | pub struct Theme { | |
| 10 | − | #[serde(deserialize_with = "color")] | |
| 11 | − | pub accent: Color, | |
| 12 | 10 | #[serde(deserialize_with = "color")] | |
| 13 | 11 | pub text: Color, | |
| 14 | 12 | #[serde(deserialize_with = "color")] | |
| 15 | 13 | pub dim: Color, | |
| 16 | 14 | #[serde(deserialize_with = "color")] | |
| 15 | + | pub border: Color, | |
| 16 | + | #[serde(deserialize_with = "color")] | |
| 17 | 17 | pub selection_fg: Color, | |
| 18 | 18 | #[serde(deserialize_with = "color")] | |
| 19 | 19 | pub selection_bg: Color, | |
| @@ -24,9 +24,17 @@ pub struct Theme { | |||
| 24 | 24 | #[serde(deserialize_with = "color")] | |
| 25 | 25 | pub directory: Color, | |
| 26 | 26 | #[serde(deserialize_with = "color")] | |
| 27 | − | pub border: Color, | |
| 28 | − | #[serde(deserialize_with = "color")] | |
| 29 | 27 | 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, | |
| 30 | 38 | #[serde(deserialize_with = "colors")] | |
| 31 | 39 | pub visualizer: Vec<Color>, | |
| 32 | 40 | } | |
| @@ -40,22 +48,27 @@ impl Theme { | |||
| 40 | 48 | Style::new().fg(self.dim) | |
| 41 | 49 | } | |
| 42 | 50 | ||
| 43 | − | pub fn accent(&self) -> Style { | |
| 44 | − | Style::new().fg(self.accent) | |
| 45 | − | } | |
| 46 | − | ||
| 47 | 51 | pub fn border(&self) -> Style { | |
| 48 | 52 | Style::new().fg(self.border) | |
| 49 | 53 | } | |
| 50 | 54 | ||
| 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. | |
| 52 | 61 | 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 | + | } | |
| 54 | 67 | } | |
| 55 | 68 | ||
| 56 | − | /// The highlighted row of a list without focus. | |
| 69 | + | /// The selected row of a list without focus. | |
| 57 | 70 | pub fn selection_inactive(&self) -> Style { | |
| 58 | − | Style::new().add_modifier(Modifier::REVERSED) | |
| 71 | + | self.selection().add_modifier(Modifier::DIM) | |
| 59 | 72 | } | |
| 60 | 73 | ||
| 61 | 74 | pub fn playing(&self) -> Style { | |
| @@ -67,7 +80,7 @@ impl Theme { | |||
| 67 | 80 | } | |
| 68 | 81 | ||
| 69 | 82 | pub fn directory(&self) -> Style { | |
| 70 | − | Style::new().fg(self.directory).add_modifier(Modifier::BOLD) | |
| 83 | + | Style::new().fg(self.directory) | |
| 71 | 84 | } | |
| 72 | 85 | ||
| 73 | 86 | pub fn error(&self) -> Style { | |
| @@ -20,6 +20,23 @@ pub enum Address { | |||
| 20 | 20 | Unix(PathBuf), | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 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 | + | ||
| 23 | 40 | impl fmt::Display for Address { | |
| 24 | 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 25 | 42 | match self { | |
| @@ -2,7 +2,7 @@ use ratatui::{ | |||
| 2 | 2 | Frame, | |
| 3 | 3 | layout::{Constraint, Rect}, | |
| 4 | 4 | text::Span, | |
| 5 | − | widgets::{Block, Row}, | |
| 5 | + | widgets::Row, | |
| 6 | 6 | }; | |
| 7 | 7 | ||
| 8 | 8 | use super::{InputPopup, InputPurpose, Popup, PopupState, frame_popup}; | |
| @@ -81,7 +81,6 @@ impl Popup for AddToPlaylistPopup { | |||
| 81 | 81 | self.list.render( | |
| 82 | 82 | frame, | |
| 83 | 83 | inner, | |
| 84 | − | Block::new(), | |
| 85 | 84 | None, | |
| 86 | 85 | &[Constraint::Fill(1)], | |
| 87 | 86 | choices.len(), | |
| @@ -89,8 +88,8 @@ impl Popup for AddToPlaylistPopup { | |||
| 89 | 88 | theme, | |
| 90 | 89 | |i| { | |
| 91 | 90 | 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()), | |
| 94 | 93 | Choice::Playlist(name) => Span::styled(name.clone(), theme.text()), | |
| 95 | 94 | }]) | |
| 96 | 95 | }, | |
| @@ -37,9 +37,9 @@ impl Popup for ConfirmPopup { | |||
| 37 | 37 | Line::from(self.message.as_str()), | |
| 38 | 38 | Line::default(), | |
| 39 | 39 | Line::from(vec![ | |
| 40 | − | Span::styled("[y]", theme.accent()), | |
| 40 | + | Span::styled("[y]", theme.header()), | |
| 41 | 41 | Span::raw("es "), | |
| 42 | − | Span::styled("[n]", theme.accent()), | |
| 42 | + | Span::styled("[n]", theme.header()), | |
| 43 | 43 | Span::raw("o"), | |
| 44 | 44 | ]), | |
| 45 | 45 | ]; | |
| @@ -2,7 +2,7 @@ use ratatui::{ | |||
| 2 | 2 | Frame, | |
| 3 | 3 | layout::{Constraint, Rect}, | |
| 4 | 4 | text::Span, | |
| 5 | − | widgets::{Block, Row}, | |
| 5 | + | widgets::Row, | |
| 6 | 6 | }; | |
| 7 | 7 | ||
| 8 | 8 | use super::{Popup, PopupState, frame_popup}; | |
| @@ -101,14 +101,13 @@ impl Popup for HelpPopup { | |||
| 101 | 101 | self.list.render( | |
| 102 | 102 | frame, | |
| 103 | 103 | inner, | |
| 104 | − | Block::new(), | |
| 105 | 104 | None, | |
| 106 | 105 | &[Constraint::Length(24), Constraint::Fill(1)], | |
| 107 | 106 | rows.len(), | |
| 108 | 107 | true, | |
| 109 | 108 | theme, | |
| 110 | 109 | |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())]), | |
| 112 | 111 | HelpRow::Binding { keys, description } => Row::new([ | |
| 113 | 112 | Span::styled(format!(" {keys}"), theme.text()), | |
| 114 | 113 | Span::styled(*description, theme.dim()), | |
| @@ -17,6 +17,7 @@ pub enum InputPurpose { | |||
| 17 | 17 | RenamePlaylist(String), | |
| 18 | 18 | SaveQueue, | |
| 19 | 19 | TagValue(TagField), | |
| 20 | + | FileName, | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | pub struct InputPopup { | |
| @@ -57,6 +58,10 @@ impl InputPopup { | |||
| 57 | 58 | ctx.client.exec(commands::save(&value)); | |
| 58 | 59 | ctx.notify(format!("Saved the queue as “{value}”")); | |
| 59 | 60 | } | |
| 61 | + | InputPurpose::FileName => ctx.effects.push(Effect::Message( | |
| 62 | + | ScreenId::TagEditor, | |
| 63 | + | ScreenMessage::FileName(value), | |
| 64 | + | )), | |
| 60 | 65 | InputPurpose::TagValue(field) => ctx.effects.push(Effect::Message( | |
| 61 | 66 | ScreenId::TagEditor, | |
| 62 | 67 | ScreenMessage::TagValue { | |
| @@ -50,9 +50,9 @@ fn frame_popup( | |||
| 50 | 50 | .flex(Flex::Center) | |
| 51 | 51 | .areas(area); | |
| 52 | 52 | 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())); | |
| 56 | 56 | let inner = block.inner(area); | |
| 57 | 57 | frame.render_widget(Clear, area); | |
| 58 | 58 | frame.render_widget(block, area); | |
| @@ -6,8 +6,8 @@ use std::collections::{HashMap, HashSet}; | |||
| 6 | 6 | use crossterm::event::MouseEvent; | |
| 7 | 7 | use ratatui::{ | |
| 8 | 8 | Frame, | |
| 9 | − | layout::{Constraint, Rect}, | |
| 10 | − | style::Modifier, | |
| 9 | + | layout::{Constraint, Layout, Rect}, | |
| 10 | + | style::{Modifier, Style}, | |
| 11 | 11 | text::{Line, Span}, | |
| 12 | 12 | widgets::{Cell, Row}, | |
| 13 | 13 | }; | |
| @@ -19,9 +19,8 @@ use crate::{ | |||
| 19 | 19 | popups::AddToPlaylistPopup, | |
| 20 | 20 | ui::{ | |
| 21 | 21 | self, | |
| 22 | − | list::{Click, ListView}, | |
| 22 | + | list::{Click, Header, ListView}, | |
| 23 | 23 | }, | |
| 24 | − | util::format_duration, | |
| 25 | 24 | }; | |
| 26 | 25 | ||
| 27 | 26 | #[derive(Debug, Default)] | |
| @@ -175,70 +174,62 @@ fn sort_entries(entries: &mut [DirEntry]) { | |||
| 175 | 174 | } | |
| 176 | 175 | ||
| 177 | 176 | impl Screen for BrowserScreen { | |
| 177 | + | fn title(&self, _ctx: &Ctx) -> Line<'static> { | |
| 178 | + | Line::from(format!("Browse: /{}", ui::clean(&self.path))) | |
| 179 | + | } | |
| 180 | + | ||
| 178 | 181 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 179 | 182 | 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; | |
| 196 | 185 | let entries = &self.entries; | |
| 197 | 186 | let marks = &self.marks; | |
| 198 | 187 | self.list.render( | |
| 199 | 188 | frame, | |
| 200 | 189 | 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), | |
| 204 | 192 | entries.len(), | |
| 205 | 193 | true, | |
| 206 | 194 | theme, | |
| 207 | 195 | |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())), | |
| 212 | 200 | theme.directory(), | |
| 213 | − | ), | |
| 201 | + | )) | |
| 202 | + | .column_span(span)]), | |
| 214 | 203 | 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)) | |
| 217 | 207 | } 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 | + | } | |
| 228 | 210 | } | |
| 211 | + | DirEntry::Playlist(name) => Row::new([Cell::from(Line::from(vec![ | |
| 212 | + | Span::styled("playlist ", theme.dim()), | |
| 213 | + | Span::styled(ui::clean(name), theme.text()), | |
| 214 | + | ])) | |
| 215 | + | .column_span(span)]), | |
| 229 | 216 | }; | |
| 230 | − | let style = if marks.contains(&i) { | |
| 231 | − | theme.marked() | |
| 217 | + | if marks.contains(&i) { | |
| 218 | + | row.style(theme.marked()) | |
| 232 | 219 | } 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 | + | } | |
| 240 | 222 | }, | |
| 241 | 223 | ); | |
| 224 | + | if entries.is_empty() && self.loaded { | |
| 225 | + | let [_, _, rest] = Layout::vertical([ | |
| 226 | + | Constraint::Length(1), | |
| 227 | + | Constraint::Length(1), | |
| 228 | + | Constraint::Fill(1), | |
| 229 | + | ]) | |
| 230 | + | .areas(area); | |
| 231 | + | ui::draw_placeholder(frame, rest, &["This folder is empty"], theme); | |
| 232 | + | } | |
| 242 | 233 | } | |
| 243 | 234 | ||
| 244 | 235 | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| @@ -3,9 +3,9 @@ | |||
| 3 | 3 | use crossterm::event::MouseEvent; | |
| 4 | 4 | use ratatui::{ | |
| 5 | 5 | 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}, | |
| 9 | 9 | widgets::{Cell, Row}, | |
| 10 | 10 | }; | |
| 11 | 11 | ||
| @@ -19,7 +19,7 @@ use crate::{ | |||
| 19 | 19 | popups::AddToPlaylistPopup, | |
| 20 | 20 | ui::{ | |
| 21 | 21 | self, | |
| 22 | − | list::{Click, ListView}, | |
| 22 | + | list::{Click, Header, ListView}, | |
| 23 | 23 | }, | |
| 24 | 24 | util::format_duration, | |
| 25 | 25 | }; | |
| @@ -172,112 +172,95 @@ fn unknown(value: &str) -> &str { | |||
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | 174 | impl Screen for LibraryScreen { | |
| 175 | + | fn title(&self, _ctx: &Ctx) -> Line<'static> { | |
| 176 | + | Line::from("Media library") | |
| 177 | + | } | |
| 178 | + | ||
| 175 | 179 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 176 | 180 | 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"); | |
| 183 | 192 | ||
| 184 | 193 | let artists = &self.artists; | |
| 185 | 194 | let title = if self.use_artist { | |
| 186 | − | " Artists " | |
| 195 | + | "Artists" | |
| 187 | 196 | } else { | |
| 188 | − | " Album Artists " | |
| 197 | + | "Album artists" | |
| 189 | 198 | }; | |
| 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 | + | ); | |
| 213 | 209 | ||
| 214 | 210 | 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 | + | ); | |
| 240 | 230 | ||
| 241 | 231 | 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 | + | ); | |
| 281 | 264 | } | |
| 282 | 265 | ||
| 283 | 266 | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| @@ -3,8 +3,7 @@ | |||
| 3 | 3 | use crossterm::event::{MouseEvent, MouseEventKind}; | |
| 4 | 4 | use ratatui::{ | |
| 5 | 5 | Frame, | |
| 6 | − | layout::{Alignment, Rect}, | |
| 7 | − | style::Modifier, | |
| 6 | + | layout::{Alignment, Constraint, Layout, Rect}, | |
| 8 | 7 | text::{Line, Span}, | |
| 9 | 8 | widgets::Paragraph, | |
| 10 | 9 | }; | |
| @@ -24,20 +23,22 @@ pub struct LyricsScreen { | |||
| 24 | 23 | file: Option<String>, | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 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); | |
| 30 | 38 | } | |
| 31 | 39 | ||
| 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 { | |
| 33 | 41 | 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); | |
| 41 | 42 | let height = usize::from(inner.height); | |
| 42 | 43 | ||
| 43 | 44 | 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 | |||
| 71 | 72 | .map(|i| { | |
| 72 | 73 | let text = lyrics.line(i); | |
| 73 | 74 | 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(), | |
| 77 | 76 | (Lyrics::Synced(_), Some(c)) if i < c => theme.dim(), | |
| 78 | 77 | (Lyrics::Synced(_), None) => theme.dim(), | |
| 79 | 78 | _ => theme.text(), | |
| @@ -86,6 +85,13 @@ fn render(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool, scroll: usize | |||
| 86 | 85 | } | |
| 87 | 86 | ||
| 88 | 87 | 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 | + | ||
| 89 | 95 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 90 | 96 | // Plain lyrics start from the top for every song. | |
| 91 | 97 | let file = ctx.mpd.current.as_ref().map(|s| s.file.clone()); | |
| @@ -93,7 +99,7 @@ impl Screen for LyricsScreen { | |||
| 93 | 99 | self.file = file; | |
| 94 | 100 | self.scroll = 0; | |
| 95 | 101 | } | |
| 96 | − | self.height = render(frame, area, ctx, true, self.scroll); | |
| 102 | + | self.height = render(frame, area, ctx, self.scroll); | |
| 97 | 103 | } | |
| 98 | 104 | ||
| 99 | 105 | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| @@ -12,7 +12,7 @@ mod visualizer; | |||
| 12 | 12 | use std::sync::Arc; | |
| 13 | 13 | ||
| 14 | 14 | use crossterm::event::MouseEvent; | |
| 15 | − | use ratatui::{Frame, layout::Rect}; | |
| 15 | + | use ratatui::{Frame, layout::Rect, text::Line}; | |
| 16 | 16 | use tokio::sync::watch; | |
| 17 | 17 | ||
| 18 | 18 | pub use browser::BrowserScreen; | |
| @@ -95,9 +95,13 @@ pub enum Effect { | |||
| 95 | 95 | #[derive(Clone, Debug)] | |
| 96 | 96 | pub enum ScreenMessage { | |
| 97 | 97 | TagValue { field: TagField, value: String }, | |
| 98 | + | FileName(String), | |
| 98 | 99 | } | |
| 99 | 100 | ||
| 100 | 101 | pub trait Screen { | |
| 102 | + | /// The text of the title row. | |
| 103 | + | fn title(&self, ctx: &Ctx) -> Line<'static>; | |
| 104 | + | ||
| 101 | 105 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx); | |
| 102 | 106 | ||
| 103 | 107 | /// Returns whether the action was handled. | |
| @@ -3,8 +3,8 @@ | |||
| 3 | 3 | use crossterm::event::MouseEvent; | |
| 4 | 4 | use ratatui::{ | |
| 5 | 5 | Frame, | |
| 6 | − | layout::{Constraint, Layout, Position, Rect}, | |
| 7 | − | text::{Line, Span}, | |
| 6 | + | layout::{Constraint, Position, Rect}, | |
| 7 | + | text::Line, | |
| 8 | 8 | widgets::{Cell, Row}, | |
| 9 | 9 | }; | |
| 10 | 10 | ||
| @@ -15,9 +15,8 @@ use crate::{ | |||
| 15 | 15 | popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose}, | |
| 16 | 16 | ui::{ | |
| 17 | 17 | self, | |
| 18 | − | list::{Click, ListView}, | |
| 18 | + | list::{Click, Header, ListView}, | |
| 19 | 19 | }, | |
| 20 | − | util::format_duration, | |
| 21 | 20 | }; | |
| 22 | 21 | ||
| 23 | 22 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| @@ -102,102 +101,51 @@ impl PlaylistsScreen { | |||
| 102 | 101 | } | |
| 103 | 102 | ||
| 104 | 103 | impl Screen for PlaylistsScreen { | |
| 104 | + | fn title(&self, _ctx: &Ctx) -> Line<'static> { | |
| 105 | + | Line::from("Playlist editor") | |
| 106 | + | } | |
| 107 | + | ||
| 105 | 108 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 106 | 109 | 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"); | |
| 110 | 117 | ||
| 111 | 118 | let playlists = &ctx.mpd.playlists; | |
| 112 | 119 | 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(), | |
| 115 | 126 | list_focused, | |
| 116 | 127 | theme, | |
| 128 | + | |i| Row::new([Cell::from(ui::clean(&playlists[i].name))]), | |
| 117 | 129 | ); | |
| 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 | − | } | |
| 140 | 130 | ||
| 141 | 131 | 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; | |
| 165 | 133 | self.songs.render( | |
| 166 | 134 | frame, | |
| 167 | 135 | 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), | |
| 176 | 138 | contents.len(), | |
| 177 | 139 | !list_focused, | |
| 178 | 140 | theme, | |
| 179 | 141 | |i| { | |
| 180 | 142 | let song = &contents[i]; | |
| 181 | − | let style = if ctx | |
| 143 | + | let playing = ctx | |
| 182 | 144 | .mpd | |
| 183 | 145 | .current | |
| 184 | 146 | .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) | |
| 201 | 149 | }, | |
| 202 | 150 | ); | |
| 203 | 151 | } | |
| @@ -5,23 +5,21 @@ use std::{collections::HashSet, time::Duration}; | |||
| 5 | 5 | use crossterm::event::MouseEvent; | |
| 6 | 6 | use ratatui::{ | |
| 7 | 7 | Frame, | |
| 8 | − | layout::{Constraint, Layout, Rect}, | |
| 9 | − | style::Modifier, | |
| 10 | − | text::{Line, Span}, | |
| 11 | − | widgets::{Cell, Row}, | |
| 8 | + | layout::{Constraint, Rect}, | |
| 9 | + | text::Line, | |
| 12 | 10 | }; | |
| 13 | 11 | ||
| 14 | 12 | use super::{Ctx, Screen, draw_lyrics}; | |
| 15 | 13 | use crate::{ | |
| 16 | 14 | action::Action, | |
| 17 | − | config::{ColumnField, Config}, | |
| 15 | + | config::Config, | |
| 18 | 16 | mpd::{MpdEvent, Reply, Selection, commands}, | |
| 19 | − | popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose}, | |
| 17 | + | popups::{AddToPlaylistPopup, InputPopup, InputPurpose}, | |
| 20 | 18 | ui::{ | |
| 21 | 19 | self, | |
| 22 | − | list::{Click, ListView}, | |
| 20 | + | list::{Click, Header, ListView}, | |
| 23 | 21 | }, | |
| 24 | − | util::format_duration, | |
| 22 | + | util::{format_length, plural}, | |
| 25 | 23 | }; | |
| 26 | 24 | ||
| 27 | 25 | pub struct QueueScreen { | |
| @@ -98,93 +96,55 @@ impl QueueScreen { | |||
| 98 | 96 | } | |
| 99 | 97 | ||
| 100 | 98 | 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 | + | ||
| 101 | 109 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 110 | + | let theme = ctx.theme(); | |
| 102 | 111 | let area = if self.lyrics_pane { | |
| 103 | 112 | 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); | |
| 110 | 124 | queue | |
| 111 | 125 | } else { | |
| 112 | 126 | area | |
| 113 | 127 | }; | |
| 114 | 128 | ||
| 115 | − | let theme = ctx.theme(); | |
| 116 | 129 | 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; | |
| 155 | 131 | let marks = &self.marks; | |
| 156 | 132 | self.list.render( | |
| 157 | 133 | frame, | |
| 158 | 134 | 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), | |
| 162 | 137 | queue.len(), | |
| 163 | 138 | true, | |
| 164 | 139 | theme, | |
| 165 | 140 | |i| { | |
| 166 | 141 | 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()) | |
| 177 | 145 | } 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 | + | } | |
| 188 | 148 | }, | |
| 189 | 149 | ); | |
| 190 | 150 | } | |
| @@ -211,12 +171,6 @@ impl Screen for QueueScreen { | |||
| 211 | 171 | } | |
| 212 | 172 | Action::MoveItemUp => self.move_selected(false, ctx), | |
| 213 | 173 | 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 | − | } | |
| 220 | 174 | Action::ShuffleQueue => ctx.client.exec(commands::shuffle()), | |
| 221 | 175 | Action::SaveQueue if len > 0 => { | |
| 222 | 176 | ctx.popup(InputPopup::new( | |
| @@ -1,15 +1,19 @@ | |||
| 1 | − | //! Screen 5: editing tags of local files. | |
| 1 | + | //! Screen 5: the tag editor, laid out like ncmpcpp's. | |
| 2 | 2 | //! | |
| 3 | − | //! Folders on the left, their songs in the middle, the tags of the selected | |
| 4 | − | //! (or all marked) songs on the right. Edits stay pending until saved. | |
| 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. | |
| 5 | 7 | ||
| 6 | − | use std::collections::{BTreeMap, HashMap, HashSet}; | |
| 8 | + | use std::collections::{BTreeMap, HashSet}; | |
| 7 | 9 | ||
| 8 | 10 | use crossterm::event::MouseEvent; | |
| 9 | 11 | use ratatui::{ | |
| 10 | 12 | Frame, | |
| 11 | − | layout::{Constraint, Layout, Position, Rect}, | |
| 12 | − | text::{Line, Span}, | |
| 13 | + | layout::{Constraint, Position, Rect}, | |
| 14 | + | style::Modifier, | |
| 15 | + | symbols, | |
| 16 | + | text::Line, | |
| 13 | 17 | widgets::{Cell, Row}, | |
| 14 | 18 | }; | |
| 15 | 19 | ||
| @@ -18,37 +22,110 @@ use crate::{ | |||
| 18 | 22 | action::Action, | |
| 19 | 23 | mpd::{DirEntry, MpdEvent, Query, Reply, Song, Subsystem}, | |
| 20 | 24 | popups::{InputPopup, InputPurpose}, | |
| 21 | − | tags::{TagField, TagWrite}, | |
| 25 | + | tags::{self, TagField, TagWrite}, | |
| 22 | 26 | ui::{ | |
| 23 | 27 | self, | |
| 24 | − | list::{Click, ListView}, | |
| 28 | + | list::{Click, Header, ListView}, | |
| 25 | 29 | }, | |
| 26 | 30 | }; | |
| 27 | 31 | ||
| 28 | 32 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 29 | 33 | enum Pane { | |
| 30 | 34 | #[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), | |
| 34 | 108 | } | |
| 35 | 109 | ||
| 36 | 110 | #[derive(Debug, Default)] | |
| 37 | 111 | pub struct TagEditorScreen { | |
| 38 | 112 | focus: Pane, | |
| 113 | + | /// The directory whose subdirectories are listed. | |
| 39 | 114 | path: String, | |
| 40 | 115 | stack: Vec<(String, usize)>, | |
| 41 | 116 | restore: Option<usize>, | |
| 42 | 117 | loaded: bool, | |
| 43 | 118 | folders: Vec<String>, | |
| 119 | + | /// The directory whose songs are shown in `Tags`. | |
| 120 | + | shown: Option<String>, | |
| 44 | 121 | 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, | |
| 49 | 125 | 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>, | |
| 52 | 129 | } | |
| 53 | 130 | ||
| 54 | 131 | impl TagEditorScreen { | |
| @@ -56,9 +133,23 @@ impl TagEditorScreen { | |||
| 56 | 133 | !self.path.is_empty() | |
| 57 | 134 | } | |
| 58 | 135 | ||
| 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 | + | } | |
| 62 | 153 | } | |
| 63 | 154 | ||
| 64 | 155 | fn load(&mut self, ctx: &Ctx) { | |
| @@ -66,43 +157,81 @@ impl TagEditorScreen { | |||
| 66 | 157 | ctx.client.query(Query::Directory(self.path.clone())); | |
| 67 | 158 | } | |
| 68 | 159 | ||
| 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 { | |
| 71 | 179 | return; | |
| 72 | 180 | }; | |
| 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 | + | } | |
| 76 | 195 | } | |
| 77 | − | let folder = self.folders[index - usize::from(self.has_parent())].clone(); | |
| 78 | − | self.stack | |
| 79 | − | .push((std::mem::replace(&mut self.path, folder), index)); | |
| 80 | − | self.reset_lists(); | |
| 81 | − | self.load(ctx); | |
| 82 | 196 | } | |
| 83 | 197 | ||
| 84 | 198 | fn back(&mut self, ctx: &Ctx) { | |
| 85 | 199 | if let Some((parent, index)) = self.stack.pop() { | |
| 86 | 200 | self.path = parent; | |
| 87 | − | self.reset_lists(); | |
| 201 | + | self.dirs.reset(); | |
| 202 | + | self.folders.clear(); | |
| 88 | 203 | self.restore = Some(index); | |
| 89 | 204 | self.load(ctx); | |
| 90 | 205 | } | |
| 91 | 206 | } | |
| 92 | 207 | ||
| 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 | |
| 97 | 229 | } | |
| 98 | 230 | ||
| 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. | |
| 100 | 232 | fn targets(&self) -> Vec<usize> { | |
| 101 | 233 | 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() | |
| 106 | 235 | } else { | |
| 107 | 236 | let mut marks: Vec<_> = self.marks.iter().copied().collect(); | |
| 108 | 237 | marks.sort_unstable(); | |
| @@ -114,36 +243,66 @@ impl TagEditorScreen { | |||
| 114 | 243 | fn value<'a>(&'a self, song: &'a Song, field: TagField) -> &'a str { | |
| 115 | 244 | self.pending | |
| 116 | 245 | .get(&song.file) | |
| 117 | − | .and_then(|changes| changes.get(&field)) | |
| 246 | + | .and_then(|changes| changes.tags.get(&field)) | |
| 118 | 247 | .map(String::as_str) | |
| 119 | 248 | .unwrap_or_else(|| song.tag(field.mpd_tag()).unwrap_or_default()) | |
| 120 | 249 | } | |
| 121 | 250 | ||
| 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()) | |
| 130 | 256 | } | |
| 131 | 257 | ||
| 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 { | |
| 134 | 260 | return; | |
| 261 | + | }; | |
| 262 | + | let original = song.tag(field.mpd_tag()).unwrap_or_default(); | |
| 263 | + | let changes = self.pending.entry(song.file.clone()).or_default(); | |
| 264 | + | if value == original { | |
| 265 | + | changes.tags.remove(&field); | |
| 266 | + | } else { | |
| 267 | + | changes.tags.insert(field, value); | |
| 135 | 268 | } | |
| 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 { | |
| 137 | 276 | return; | |
| 138 | 277 | }; | |
| 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() | |
| 143 | 293 | } 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()), | |
| 145 | 299 | }; | |
| 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 | + | )); | |
| 147 | 306 | ctx.popup(InputPopup::new( | |
| 148 | 307 | title, | |
| 149 | 308 | &initial, | |
| @@ -151,6 +310,53 @@ impl TagEditorScreen { | |||
| 151 | 310 | )); | |
| 152 | 311 | } | |
| 153 | 312 | ||
| 313 | + | fn change_case(&mut self, change: fn(&str) -> String) { | |
| 314 | + | for index in self.targets() { | |
| 315 | + | for field in TagField::ALL.into_iter().filter(|f| f.is_text()) { | |
| 316 | + | let current = self.value(&self.songs[index], field).to_owned(); | |
| 317 | + | let changed = change(¤t); | |
| 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 | + | ¤t, | |
| 352 | + | InputPurpose::FileName, | |
| 353 | + | )); | |
| 354 | + | } | |
| 355 | + | Item::Field(field) => self.edit_field_for(field, vec![index], ctx), | |
| 356 | + | _ => {} | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 154 | 360 | fn save(&mut self, ctx: &mut Ctx) { | |
| 155 | 361 | if self.pending.is_empty() { | |
| 156 | 362 | ctx.notify("No pending tag changes"); | |
| @@ -160,13 +366,13 @@ impl TagEditorScreen { | |||
| 160 | 366 | ctx.error("Set mpd.music_directory in the config to write tags"); | |
| 161 | 367 | return; | |
| 162 | 368 | }; | |
| 163 | − | let writes = self | |
| 164 | − | .pending | |
| 165 | − | .drain() | |
| 369 | + | let writes = std::mem::take(&mut self.pending) | |
| 370 | + | .into_iter() | |
| 166 | 371 | .map(|(uri, changes)| TagWrite { | |
| 167 | 372 | path: music_dir.join(&uri), | |
| 168 | 373 | uri, | |
| 169 | − | changes, | |
| 374 | + | changes: changes.tags, | |
| 375 | + | rename: changes.rename, | |
| 170 | 376 | }) | |
| 171 | 377 | .collect(); | |
| 172 | 378 | ctx.effects.push(Effect::WriteTags(writes)); | |
| @@ -174,141 +380,112 @@ impl TagEditorScreen { | |||
| 174 | 380 | } | |
| 175 | 381 | ||
| 176 | 382 | 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 | + | ||
| 177 | 391 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 178 | 392 | let theme = ctx.theme(); | |
| 179 | 393 | if ctx.mpd.music_dir.is_none() { | |
| 180 | − | let block = ui::pane(" Tag editor ", true, theme); | |
| 181 | − | let inner = block.inner(area); | |
| 182 | − | frame.render_widget(block, area); | |
| 183 | 394 | ui::draw_placeholder( | |
| 184 | 395 | frame, | |
| 185 | − | inner, | |
| 396 | + | area, | |
| 186 | 397 | &[ | |
| 187 | 398 | "The tag editor needs access to the music files.", | |
| 188 | 399 | "", | |
| 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.", | |
| 191 | 402 | ], | |
| 192 | 403 | theme, | |
| 193 | 404 | ); | |
| 194 | 405 | return; | |
| 195 | 406 | } | |
| 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"); | |
| 202 | 418 | ||
| 203 | 419 | let parent = self.has_parent(); | |
| 204 | 420 | 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( | |
| 210 | 422 | 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"))), | |
| 214 | 425 | &[Constraint::Fill(1)], | |
| 215 | − | folders.len() + usize::from(parent), | |
| 216 | − | self.focus == Pane::Folders, | |
| 426 | + | self.dir_rows(), | |
| 427 | + | self.focus == Pane::Directories, | |
| 217 | 428 | theme, | |
| 218 | 429 | |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 | + | } | |
| 224 | 437 | }; | |
| 225 | 438 | Row::new([Cell::from(name)]).style(theme.directory()) | |
| 226 | 439 | }, | |
| 227 | 440 | ); | |
| 228 | 441 | ||
| 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 | + | ); | |
| 280 | 457 | ||
| 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 | |
| 288 | 461 | .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 | + | }) | |
| 290 | 469 | .collect(); | |
| 291 | − | let has_targets = !targets.is_empty(); | |
| 292 | − | self.field_list.render( | |
| 470 | + | let marks = &self.marks; | |
| 471 | + | self.tags.render( | |
| 293 | 472 | 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, | |
| 300 | 478 | theme, | |
| 301 | 479 | |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) | |
| 312 | 489 | }, | |
| 313 | 490 | ); | |
| 314 | 491 | } | |
| @@ -327,92 +504,108 @@ impl Screen for TagEditorScreen { | |||
| 327 | 504 | _ => {} | |
| 328 | 505 | } | |
| 329 | 506 | 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) { | |
| 332 | 524 | return true; | |
| 333 | 525 | } | |
| 334 | 526 | 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), | |
| 338 | 530 | _ => return false, | |
| 339 | 531 | } | |
| 340 | 532 | } | |
| 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()) { | |
| 343 | 535 | return true; | |
| 344 | 536 | } | |
| 345 | 537 | 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), | |
| 348 | 540 | 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()) { | |
| 350 | 542 | if !self.marks.remove(&i) { | |
| 351 | 543 | self.marks.insert(i); | |
| 352 | 544 | } | |
| 353 | − | self.song_list.navigate(Action::Down, self.songs.len()); | |
| 545 | + | self.tags.navigate(Action::Down, self.songs.len()); | |
| 354 | 546 | } | |
| 355 | 547 | } | |
| 356 | 548 | Action::ClearMarks => self.marks.clear(), | |
| 357 | 549 | _ => return false, | |
| 358 | 550 | } | |
| 359 | 551 | } | |
| 360 | − | Pane::Fields => { | |
| 361 | − | if self.field_list.navigate(action, TagField::ALL.len()) { | |
| 362 | − | return true; | |
| 363 | − | } | |
| 364 | − | match action { | |
| 365 | − | Action::Left => self.focus = Pane::Songs, | |
| 366 | − | Action::Activate | Action::Right => self.edit_field(ctx), | |
| 367 | − | _ => return false, | |
| 368 | − | } | |
| 369 | − | } | |
| 370 | 552 | } | |
| 371 | 553 | true | |
| 372 | 554 | } | |
| 373 | 555 | ||
| 374 | 556 | 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 | + | } | |
| 384 | 564 | } | |
| 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); | |
| 387 | 567 | } | |
| 568 | + | _ => {} | |
| 388 | 569 | } | |
| 389 | 570 | } | |
| 390 | 571 | ||
| 391 | 572 | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 392 | 573 | 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()); | |
| 401 | 588 | } | |
| 589 | + | self.sync_songs(ctx); | |
| 402 | 590 | } | |
| 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(); | |
| 409 | 599 | } | |
| 410 | 600 | } | |
| 411 | 601 | MpdEvent::Changed(subsystems) | |
| 412 | 602 | if subsystems.contains(&Subsystem::Database) && self.loaded => | |
| 413 | 603 | { | |
| 414 | − | // Show freshly written tags. | |
| 604 | + | // Show freshly written tags and renamed files. | |
| 415 | 605 | ctx.client.query(Query::Directory(self.path.clone())); | |
| 606 | + | if let Some(shown) = self.shown.as_ref().filter(|s| **s != self.path) { | |
| 607 | + | ctx.client.query(Query::Directory(shown.clone())); | |
| 608 | + | } | |
| 416 | 609 | } | |
| 417 | 610 | _ => {} | |
| 418 | 611 | } | |
| @@ -421,48 +614,55 @@ impl Screen for TagEditorScreen { | |||
| 421 | 614 | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 422 | 615 | if !self.loaded { | |
| 423 | 616 | self.load(ctx); | |
| 617 | + | self.shown = None; | |
| 618 | + | self.sync_songs(ctx); | |
| 424 | 619 | } | |
| 425 | 620 | } | |
| 426 | 621 | ||
| 427 | 622 | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 428 | 623 | 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); | |
| 433 | 635 | } | |
| 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); | |
| 441 | 640 | } | |
| 442 | 641 | } | |
| 443 | 642 | } | |
| 444 | 643 | ||
| 445 | − | fn search(&mut self, query: &str, _ctx: &Ctx) -> bool { | |
| 644 | + | fn search(&mut self, query: &str, ctx: &Ctx) -> bool { | |
| 446 | 645 | match self.focus { | |
| 447 | − | Pane::Folders => { | |
| 448 | − | let mut names: Vec<&str> = Vec::new(); | |
| 646 | + | Pane::Directories => { | |
| 647 | + | let mut names = vec![".".to_owned()]; | |
| 449 | 648 | if self.has_parent() { | |
| 450 | − | names.push(".."); | |
| 649 | + | names.push("..".to_owned()); | |
| 451 | 650 | } | |
| 452 | 651 | names.extend( | |
| 453 | 652 | self.folders | |
| 454 | 653 | .iter() | |
| 455 | − | .map(|f| f.rsplit('/').next().unwrap_or(f)), | |
| 654 | + | .map(|f| f.rsplit('/').next().unwrap_or(f).to_owned()), | |
| 456 | 655 | ); | |
| 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); | |
| 459 | 659 | } | |
| 460 | − | Pane::Songs => { | |
| 660 | + | Pane::Tags => { | |
| 461 | 661 | let len = self.songs.len(); | |
| 462 | − | self.song_list | |
| 662 | + | self.tags | |
| 463 | 663 | .search(query, self.songs.iter().map(Song::file_name), len); | |
| 464 | 664 | } | |
| 465 | − | Pane::Fields => return false, | |
| 665 | + | Pane::TagTypes => return false, | |
| 466 | 666 | } | |
| 467 | 667 | true | |
| 468 | 668 | } | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::sync::Arc; | |
| 4 | 4 | ||
| 5 | − | use ratatui::{Frame, layout::Rect}; | |
| 5 | + | use ratatui::{Frame, layout::Rect, text::Line}; | |
| 6 | 6 | use tokio::sync::watch; | |
| 7 | 7 | ||
| 8 | 8 | use super::{Ctx, Screen}; | |
| @@ -29,17 +29,14 @@ impl VisualizerScreen { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | impl Screen for VisualizerScreen { | |
| 32 | + | fn title(&self, _ctx: &Ctx) -> Line<'static> { | |
| 33 | + | Line::from("Music visualizer") | |
| 34 | + | } | |
| 35 | + | ||
| 32 | 36 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 33 | 37 | let theme = ctx.theme(); | |
| 34 | 38 | 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; | |
| 43 | 40 | let spectrum = Arc::clone(&self.spectrum.borrow_and_update()); | |
| 44 | 41 | if let Some(error) = &spectrum.error { | |
| 45 | 42 | let fifo = config.fifo.display().to_string(); | |
| @@ -4,30 +4,34 @@ use std::{collections::BTreeMap, path::PathBuf}; | |||
| 4 | 4 | ||
| 5 | 5 | use lofty::{config::WriteOptions, prelude::*, tag::Tag}; | |
| 6 | 6 | ||
| 7 | − | /// The tags the editor can change. | |
| 7 | + | /// The tags the editor can change, in ncmpcpp's order. | |
| 8 | 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 9 | 9 | pub enum TagField { | |
| 10 | 10 | Title, | |
| 11 | 11 | Artist, | |
| 12 | 12 | AlbumArtist, | |
| 13 | 13 | Album, | |
| 14 | − | Track, | |
| 15 | − | Disc, | |
| 16 | 14 | Date, | |
| 15 | + | Track, | |
| 17 | 16 | Genre, | |
| 17 | + | Composer, | |
| 18 | + | Performer, | |
| 19 | + | Disc, | |
| 18 | 20 | Comment, | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | impl TagField { | |
| 22 | − | pub const ALL: [Self; 9] = [ | |
| 24 | + | pub const ALL: [Self; 11] = [ | |
| 23 | 25 | Self::Title, | |
| 24 | 26 | Self::Artist, | |
| 25 | 27 | Self::AlbumArtist, | |
| 26 | 28 | Self::Album, | |
| 27 | − | Self::Track, | |
| 28 | − | Self::Disc, | |
| 29 | 29 | Self::Date, | |
| 30 | + | Self::Track, | |
| 30 | 31 | Self::Genre, | |
| 32 | + | Self::Composer, | |
| 33 | + | Self::Performer, | |
| 34 | + | Self::Disc, | |
| 31 | 35 | Self::Comment, | |
| 32 | 36 | ]; | |
| 33 | 37 | ||
| @@ -37,10 +41,12 @@ impl TagField { | |||
| 37 | 41 | Self::Artist => "Artist", | |
| 38 | 42 | Self::AlbumArtist => "Album Artist", | |
| 39 | 43 | Self::Album => "Album", | |
| 40 | − | Self::Track => "Track", | |
| 41 | − | Self::Disc => "Disc", | |
| 42 | 44 | Self::Date => "Date", | |
| 45 | + | Self::Track => "Track", | |
| 43 | 46 | Self::Genre => "Genre", | |
| 47 | + | Self::Composer => "Composer", | |
| 48 | + | Self::Performer => "Performer", | |
| 49 | + | Self::Disc => "Disc", | |
| 44 | 50 | Self::Comment => "Comment", | |
| 45 | 51 | } | |
| 46 | 52 | } | |
| @@ -48,65 +54,133 @@ impl TagField { | |||
| 48 | 54 | /// The tag name MPD reports for this field. | |
| 49 | 55 | pub fn mpd_tag(self) -> &'static str { | |
| 50 | 56 | match self { | |
| 51 | − | Self::Title => "Title", | |
| 52 | − | Self::Artist => "Artist", | |
| 53 | 57 | 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(), | |
| 60 | 59 | } | |
| 61 | 60 | } | |
| 62 | 61 | ||
| 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 | + | ||
| 63 | 67 | fn item_key(self) -> ItemKey { | |
| 64 | 68 | match self { | |
| 65 | 69 | Self::Title => ItemKey::TrackTitle, | |
| 66 | 70 | Self::Artist => ItemKey::TrackArtist, | |
| 67 | 71 | Self::AlbumArtist => ItemKey::AlbumArtist, | |
| 68 | 72 | Self::Album => ItemKey::AlbumTitle, | |
| 69 | − | Self::Track => ItemKey::TrackNumber, | |
| 70 | − | Self::Disc => ItemKey::DiscNumber, | |
| 71 | 73 | Self::Date => ItemKey::RecordingDate, | |
| 74 | + | Self::Track => ItemKey::TrackNumber, | |
| 72 | 75 | Self::Genre => ItemKey::Genre, | |
| 76 | + | Self::Composer => ItemKey::Composer, | |
| 77 | + | Self::Performer => ItemKey::Performer, | |
| 78 | + | Self::Disc => ItemKey::DiscNumber, | |
| 73 | 79 | Self::Comment => ItemKey::Comment, | |
| 74 | 80 | } | |
| 75 | 81 | } | |
| 76 | 82 | } | |
| 77 | 83 | ||
| 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)] | |
| 80 | 86 | pub struct TagWrite { | |
| 81 | 87 | pub path: PathBuf, | |
| 82 | 88 | /// Library-relative path, for reporting and rescanning. | |
| 83 | 89 | pub uri: String, | |
| 90 | + | /// New tag values; an empty value removes the tag. | |
| 84 | 91 | pub changes: BTreeMap<TagField, String>, | |
| 92 | + | /// A new file name (without directory), if the file is to be renamed. | |
| 93 | + | pub rename: Option<String>, | |
| 85 | 94 | } | |
| 86 | 95 | ||
| 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. | |
| 88 | 98 | pub fn write(write: &TagWrite) -> Result<(), String> { | |
| 89 | 99 | 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)); | |
| 93 | 104 | } | |
| 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 | + | } | |
| 108 | 125 | } | |
| 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 | + | ); | |
| 109 | 185 | } | |
| 110 | − | tag.save_to_path(&write.path, WriteOptions::default()) | |
| 111 | − | .map_err(|e| describe(&e)) | |
| 112 | 186 | } | |
| @@ -121,7 +121,7 @@ impl LineInput { | |||
| 121 | 121 | width += w; | |
| 122 | 122 | } | |
| 123 | 123 | let line = Line::from(vec![ | |
| 124 | − | Span::styled(prefix.to_owned(), theme.accent()), | |
| 124 | + | Span::styled(prefix.to_owned(), theme.header()), | |
| 125 | 125 | Span::styled(visible, theme.text()), | |
| 126 | 126 | ]); | |
| 127 | 127 | frame.render_widget(line, area); | |
| @@ -9,12 +9,13 @@ use nucleo_matcher::{ | |||
| 9 | 9 | }; | |
| 10 | 10 | use ratatui::{ | |
| 11 | 11 | Frame, | |
| 12 | − | layout::{Constraint, Position, Rect}, | |
| 12 | + | layout::{Constraint, Layout, Position, Rect}, | |
| 13 | 13 | style::{Modifier, Style}, | |
| 14 | − | widgets::{Block, Row, Table, TableState}, | |
| 14 | + | text::Line, | |
| 15 | + | widgets::{Row, Table, TableState}, | |
| 15 | 16 | }; | |
| 16 | 17 | ||
| 17 | − | use crate::{action::Action, config::Theme}; | |
| 18 | + | use crate::{action::Action, config::Theme, ui}; | |
| 18 | 19 | ||
| 19 | 20 | /// Cursor, scroll offset and search state of a list. | |
| 20 | 21 | /// | |
| @@ -137,28 +138,42 @@ impl ListView { | |||
| 137 | 138 | } | |
| 138 | 139 | } | |
| 139 | 140 | ||
| 140 | − | /// Draws rows `offset..offset + height` inside `block`. | |
| 141 | + | /// Draws an optional header with a `─` line below it, then rows | |
| 142 | + | /// `offset..offset + height`. | |
| 141 | 143 | #[allow(clippy::too_many_arguments)] | |
| 142 | 144 | pub fn render<'a>( | |
| 143 | 145 | &mut self, | |
| 144 | 146 | frame: &mut Frame, | |
| 145 | 147 | area: Rect, | |
| 146 | − | block: Block<'a>, | |
| 147 | − | header: Option<Row<'a>>, | |
| 148 | + | header: Option<Header<'a>>, | |
| 148 | 149 | widths: &[Constraint], | |
| 149 | 150 | len: usize, | |
| 150 | 151 | focused: bool, | |
| 151 | 152 | theme: &Theme, | |
| 152 | 153 | mut row: impl FnMut(usize) -> Row<'a>, | |
| 153 | 154 | ) { | |
| 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); | |
| 162 | 177 | self.selected = self.selected.min(len.saturating_sub(1)); | |
| 163 | 178 | if self.selected < self.offset { | |
| 164 | 179 | self.offset = self.selected; | |
| @@ -183,21 +198,25 @@ impl ListView { | |||
| 183 | 198 | } else { | |
| 184 | 199 | theme.selection_inactive() | |
| 185 | 200 | }; | |
| 186 | − | let mut table = Table::new(rows, widths.to_vec()) | |
| 187 | − | .block(block) | |
| 201 | + | let table = Table::new(rows, widths.to_vec()) | |
| 188 | 202 | .column_spacing(1) | |
| 189 | 203 | .row_highlight_style(highlight); | |
| 190 | − | if let Some(header) = header { | |
| 191 | − | table = table.header(header); | |
| 192 | − | } | |
| 193 | 204 | let mut state = TableState::default(); | |
| 194 | 205 | if len > 0 { | |
| 195 | 206 | state.select(Some(self.selected - self.offset)); | |
| 196 | 207 | } | |
| 197 | − | frame.render_stateful_widget(table, area, &mut state); | |
| 208 | + | frame.render_stateful_widget(table, rows_area, &mut state); | |
| 198 | 209 | } | |
| 199 | 210 | } | |
| 200 | 211 | ||
| 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 | + | ||
| 201 | 220 | /// Fuzzy search results over a list, kept in list order so that `n`/`N` | |
| 202 | 221 | /// walk through them like in a pager. | |
| 203 | 222 | #[derive(Debug, Default)] | |
| @@ -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 | + | //! ``` | |
| 2 | 10 | ||
| 3 | 11 | pub mod input; | |
| 4 | 12 | pub mod list; | |
| @@ -7,73 +15,25 @@ use std::time::Duration; | |||
| 7 | 15 | ||
| 8 | 16 | use ratatui::{ | |
| 9 | 17 | Frame, | |
| 10 | − | layout::{Alignment, Constraint, Layout, Rect}, | |
| 18 | + | layout::{Constraint, Layout, Rect}, | |
| 11 | 19 | style::{Modifier, Style}, | |
| 20 | + | symbols, | |
| 12 | 21 | text::{Line, Span}, | |
| 13 | − | widgets::{Block, BorderType, Paragraph}, | |
| 22 | + | widgets::{Cell, Paragraph, Row}, | |
| 14 | 23 | }; | |
| 15 | 24 | use unicode_width::UnicodeWidthStr; | |
| 16 | 25 | ||
| 17 | 26 | use crate::{ | |
| 18 | − | action::ScreenId, | |
| 19 | − | config::{ColumnField, Theme}, | |
| 27 | + | config::{Column, ColumnField, Theme}, | |
| 20 | 28 | mpd::{PlayState, Song}, | |
| 21 | 29 | state::MpdState, | |
| 22 | 30 | util::format_duration, | |
| 23 | 31 | }; | |
| 24 | 32 | ||
| 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 | − | ||
| 73 | 33 | /// The rows of the frame around the active screen. | |
| 74 | 34 | pub struct FrameLayout { | |
| 75 | − | pub header: Rect, | |
| 76 | − | pub tabs: Rect, | |
| 35 | + | pub title: Rect, | |
| 36 | + | pub separator: Rect, | |
| 77 | 37 | pub body: Rect, | |
| 78 | 38 | pub progress: Rect, | |
| 79 | 39 | pub status: Rect, | |
| @@ -81,8 +41,8 @@ pub struct FrameLayout { | |||
| 81 | 41 | ||
| 82 | 42 | impl FrameLayout { | |
| 83 | 43 | 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), | |
| 86 | 46 | Constraint::Length(1), | |
| 87 | 47 | Constraint::Fill(1), | |
| 88 | 48 | Constraint::Length(1), | |
| @@ -90,8 +50,8 @@ impl FrameLayout { | |||
| 90 | 50 | ]) | |
| 91 | 51 | .areas(area); | |
| 92 | 52 | Self { | |
| 93 | − | header, | |
| 94 | − | tabs, | |
| 53 | + | title, | |
| 54 | + | separator, | |
| 95 | 55 | body, | |
| 96 | 56 | progress, | |
| 97 | 57 | status, | |
| @@ -99,230 +59,279 @@ impl FrameLayout { | |||
| 99 | 59 | } | |
| 100 | 60 | } | |
| 101 | 61 | ||
| 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(), | |
| 125 | 75 | }; | |
| 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); | |
| 126 | 83 | ||
| 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 | + | ); | |
| 155 | 107 | } | |
| 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 | + | } | |
| 168 | 109 | ||
| 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) { | |
| 179 | 112 | 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, | |
| 182 | 118 | ); | |
| 183 | 119 | } | |
| 184 | 120 | ||
| 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( | |
| 210 | 123 | frame: &mut Frame, | |
| 211 | 124 | area: Rect, | |
| 212 | − | active: ScreenId, | |
| 125 | + | constraints: &[Constraint], | |
| 213 | 126 | 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); | |
| 223 | 143 | } 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(), | |
| 225 | 187 | }; | |
| 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 | |
| 230 | 202 | } | |
| 231 | − | frame.render_widget(Line::from(spans), area); | |
| 232 | − | hits | |
| 233 | 203 | } | |
| 234 | 204 | ||
| 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. | |
| 236 | 250 | pub fn draw_progress(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) { | |
| 237 | − | let elapsed = mpd.elapsed(); | |
| 238 | 251 | 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); | |
| 246 | 253 | let filled = if total.is_zero() || mpd.status.state == PlayState::Stop { | |
| 247 | 254 | 0 | |
| 248 | 255 | } 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 | |
| 250 | 257 | } | |
| 251 | 258 | .min(width); | |
| 252 | 259 | 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, | |
| 258 | 265 | ); | |
| 259 | 266 | } | |
| 260 | 267 | ||
| 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 | − | ||
| 270 | 268 | /// Maps a click on the progress bar to a position in the song. | |
| 271 | 269 | pub fn progress_seek_target(area: Rect, x: u16, mpd: &MpdState) -> Option<Duration> { | |
| 272 | 270 | 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() { | |
| 277 | 272 | return None; | |
| 278 | 273 | } | |
| 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); | |
| 281 | 322 | } | |
| 282 | 323 | ||
| 283 | 324 | /// A one-line message, styled as an error if needed. | |
| 284 | 325 | pub fn draw_message(frame: &mut Frame, area: Rect, text: &str, error: bool, theme: &Theme) { | |
| 285 | 326 | 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); | |
| 298 | 328 | } | |
| 299 | 329 | ||
| 300 | 330 | /// A centered, dimmed placeholder for empty panes. | |
| 301 | 331 | pub fn draw_placeholder(frame: &mut Frame, area: Rect, lines: &[&str], theme: &Theme) { | |
| 302 | 332 | let top = area.height.saturating_sub(lines.len() as u16) / 2; | |
| 303 | 333 | 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()))) | |
| 309 | 335 | .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); | |
| 328 | 337 | } | |
| @@ -35,10 +35,47 @@ pub fn format_duration(duration: Duration) -> String { | |||
| 35 | 35 | } | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 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 | + | ||
| 38 | 64 | #[cfg(test)] | |
| 39 | 65 | mod tests { | |
| 40 | 66 | use super::*; | |
| 41 | 67 | ||
| 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 | + | ||
| 42 | 79 | #[test] | |
| 43 | 80 | fn formats_durations() { | |
| 44 | 81 | assert_eq!(format_duration(Duration::from_secs(5)), "0:05"); | |