| 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 | //! ``` |
| 10 | |
| 11 | pub mod input; |
| 12 | pub mod list; |
| 13 | |
| 14 | use std::time::Duration; |
| 15 | |
| 16 | use ratatui::{ |
| 17 | Frame, |
| 18 | layout::{Constraint, Layout, Rect}, |
| 19 | style::{Modifier, Style}, |
| 20 | symbols, |
| 21 | text::{Line, Span}, |
| 22 | widgets::{Cell, Paragraph, Row}, |
| 23 | }; |
| 24 | use unicode_width::UnicodeWidthStr; |
| 25 | |
| 26 | use crate::{ |
| 27 | config::{Column, ColumnField, Theme}, |
| 28 | mpd::{PlayState, Song}, |
| 29 | state::MpdState, |
| 30 | util::format_duration, |
| 31 | }; |
| 32 | |
| 33 | /// The rows of the frame around the active screen. |
| 34 | pub struct FrameLayout { |
| 35 | pub title: Rect, |
| 36 | pub separator: Rect, |
| 37 | pub body: Rect, |
| 38 | pub progress: Rect, |
| 39 | pub status: Rect, |
| 40 | } |
| 41 | |
| 42 | impl FrameLayout { |
| 43 | pub fn new(area: Rect) -> Self { |
| 44 | let [title, separator, body, progress, status] = Layout::vertical([ |
| 45 | Constraint::Length(1), |
| 46 | Constraint::Length(1), |
| 47 | Constraint::Fill(1), |
| 48 | Constraint::Length(1), |
| 49 | Constraint::Length(1), |
| 50 | ]) |
| 51 | .areas(area); |
| 52 | Self { |
| 53 | title, |
| 54 | separator, |
| 55 | body, |
| 56 | progress, |
| 57 | status, |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 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(), |
| 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); |
| 83 | |
| 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 | ); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// A horizontal `─` line across `area`. |
| 111 | pub fn draw_hline(frame: &mut Frame, area: Rect, theme: &Theme) { |
| 112 | frame.render_widget( |
| 113 | Span::styled( |
| 114 | symbols::line::HORIZONTAL.repeat(usize::from(area.width)), |
| 115 | theme.border(), |
| 116 | ), |
| 117 | area, |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | /// Splits `area` into columns separated by `│` lines and draws the lines. |
| 122 | pub fn split_columns( |
| 123 | frame: &mut Frame, |
| 124 | area: Rect, |
| 125 | constraints: &[Constraint], |
| 126 | theme: &Theme, |
| 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); |
| 143 | } else { |
| 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(), |
| 187 | }; |
| 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 |
| 202 | } |
| 203 | } |
| 204 | |
| 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. |
| 250 | pub fn draw_progress(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) { |
| 251 | let total = mpd.status.duration.unwrap_or_default(); |
| 252 | let width = usize::from(area.width); |
| 253 | let filled = if total.is_zero() || mpd.status.state == PlayState::Stop { |
| 254 | 0 |
| 255 | } else { |
| 256 | ((mpd.elapsed().as_secs_f64() / total.as_secs_f64()) * width as f64).round() as usize |
| 257 | } |
| 258 | .min(width); |
| 259 | frame.render_widget( |
| 260 | Span::styled( |
| 261 | symbols::line::THICK_HORIZONTAL.repeat(filled), |
| 262 | Style::new().fg(theme.progress_filled), |
| 263 | ), |
| 264 | area, |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | /// Maps a click on the progress bar to a position in the song. |
| 269 | pub fn progress_seek_target(area: Rect, x: u16, mpd: &MpdState) -> Option<Duration> { |
| 270 | let total = mpd.status.duration?; |
| 271 | if area.width == 0 || x < area.x || x >= area.right() { |
| 272 | return None; |
| 273 | } |
| 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); |
| 322 | } |
| 323 | |
| 324 | /// A one-line message, styled as an error if needed. |
| 325 | pub fn draw_message(frame: &mut Frame, area: Rect, text: &str, error: bool, theme: &Theme) { |
| 326 | let style = if error { theme.error() } else { theme.text() }; |
| 327 | frame.render_widget(Span::styled(text.to_owned(), style), area); |
| 328 | } |
| 329 | |
| 330 | /// A centered, dimmed placeholder for empty panes. |
| 331 | pub fn draw_placeholder(frame: &mut Frame, area: Rect, lines: &[&str], theme: &Theme) { |
| 332 | let top = area.height.saturating_sub(lines.len() as u16) / 2; |
| 333 | let text: Vec<Line> = std::iter::repeat_n(Line::default(), usize::from(top)) |
| 334 | .chain(lines.iter().map(|l| Line::styled(*l, theme.dim()))) |
| 335 | .collect(); |
| 336 | frame.render_widget(Paragraph::new(text).centered(), area); |
| 337 | } |