| 1 | //! Widgets and the parts of the frame shared by all screens. |
| 2 | |
| 3 | pub mod input; |
| 4 | pub mod list; |
| 5 | |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use ratatui::{ |
| 9 | Frame, |
| 10 | layout::{Alignment, Constraint, Layout, Rect}, |
| 11 | style::{Modifier, Style}, |
| 12 | text::{Line, Span}, |
| 13 | widgets::{Block, BorderType, Paragraph}, |
| 14 | }; |
| 15 | use unicode_width::UnicodeWidthStr; |
| 16 | |
| 17 | use crate::{ |
| 18 | action::ScreenId, |
| 19 | config::{ColumnField, Theme}, |
| 20 | mpd::{PlayState, Song}, |
| 21 | state::MpdState, |
| 22 | util::format_duration, |
| 23 | }; |
| 24 | |
| 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 | /// The rows of the frame around the active screen. |
| 74 | pub struct FrameLayout { |
| 75 | pub header: Rect, |
| 76 | pub tabs: Rect, |
| 77 | pub body: Rect, |
| 78 | pub progress: Rect, |
| 79 | pub status: Rect, |
| 80 | } |
| 81 | |
| 82 | impl FrameLayout { |
| 83 | pub fn new(area: Rect) -> Self { |
| 84 | let [header, tabs, body, progress, status] = Layout::vertical([ |
| 85 | Constraint::Length(2), |
| 86 | Constraint::Length(1), |
| 87 | Constraint::Fill(1), |
| 88 | Constraint::Length(1), |
| 89 | Constraint::Length(1), |
| 90 | ]) |
| 91 | .areas(area); |
| 92 | Self { |
| 93 | header, |
| 94 | tabs, |
| 95 | body, |
| 96 | progress, |
| 97 | status, |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 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()), |
| 125 | }; |
| 126 | |
| 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)); |
| 155 | } |
| 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); |
| 168 | |
| 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 | ); |
| 179 | frame.render_widget( |
| 180 | Line::from(Span::styled(audio, theme.dim())).alignment(Alignment::Right), |
| 181 | audio_area, |
| 182 | ); |
| 183 | } |
| 184 | |
| 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( |
| 210 | frame: &mut Frame, |
| 211 | area: Rect, |
| 212 | active: ScreenId, |
| 213 | 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) |
| 223 | } else { |
| 224 | theme.dim() |
| 225 | }; |
| 226 | hits.push((x, x + width, screen)); |
| 227 | x += width + 1; |
| 228 | spans.push(Span::styled(label, style)); |
| 229 | spans.push(Span::raw(" ")); |
| 230 | } |
| 231 | frame.render_widget(Line::from(spans), area); |
| 232 | hits |
| 233 | } |
| 234 | |
| 235 | /// Draws `elapsed ━━━━──── total`. |
| 236 | pub fn draw_progress(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) { |
| 237 | let elapsed = mpd.elapsed(); |
| 238 | 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); |
| 246 | let filled = if total.is_zero() || mpd.status.state == PlayState::Stop { |
| 247 | 0 |
| 248 | } else { |
| 249 | ((elapsed.as_secs_f64() / total.as_secs_f64()) * width as f64).round() as usize |
| 250 | } |
| 251 | .min(width); |
| 252 | 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, |
| 258 | ); |
| 259 | } |
| 260 | |
| 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 | /// Maps a click on the progress bar to a position in the song. |
| 271 | pub fn progress_seek_target(area: Rect, x: u16, mpd: &MpdState) -> Option<Duration> { |
| 272 | 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() { |
| 277 | return None; |
| 278 | } |
| 279 | let fraction = f64::from(x - bar.x) / f64::from(bar.width); |
| 280 | Some(total.mul_f64(fraction)) |
| 281 | } |
| 282 | |
| 283 | /// A one-line message, styled as an error if needed. |
| 284 | pub fn draw_message(frame: &mut Frame, area: Rect, text: &str, error: bool, theme: &Theme) { |
| 285 | 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 | ); |
| 298 | } |
| 299 | |
| 300 | /// A centered, dimmed placeholder for empty panes. |
| 301 | pub fn draw_placeholder(frame: &mut Frame, area: Rect, lines: &[&str], theme: &Theme) { |
| 302 | let top = area.height.saturating_sub(lines.len() as u16) / 2; |
| 303 | 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 | ) |
| 309 | .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 | } |
| 328 | } |