Krolyxon
Commit 96dd350

rmptuisrcuimod.rs

328 lines · 10.4 KB · Rust

1//! Widgets and the parts of the frame shared by all screens.
2
3pub mod input;
4pub mod list;
5
6use std::time::Duration;
7
8use ratatui::{
9 Frame,
10 layout::{Alignment, Constraint, Layout, Rect},
11 style::{Modifier, Style},
12 text::{Line, Span},
13 widgets::{Block, BorderType, Paragraph},
14};
15use unicode_width::UnicodeWidthStr;
16
17use 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.
26pub 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.
39pub 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.
56pub 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.
64pub 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.
74pub struct FrameLayout {
75 pub header: Rect,
76 pub tabs: Rect,
77 pub body: Rect,
78 pub progress: Rect,
79 pub status: Rect,
80}
81
82impl 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
102pub 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`.
186fn 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.
209pub 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`.
236pub 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
261fn 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.
271pub 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.
284pub 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.
293pub 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.
301pub 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)]
319mod 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}