Krolyxon
Commit 222ab2f

rmptuisrcuimod.rs

337 lines · 11.1 KB · Rust

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
11pub mod input;
12pub mod list;
13
14use std::time::Duration;
15
16use ratatui::{
17 Frame,
18 layout::{Constraint, Layout, Rect},
19 style::{Modifier, Style},
20 symbols,
21 text::{Line, Span},
22 widgets::{Cell, Paragraph, Row},
23};
24use unicode_width::UnicodeWidthStr;
25
26use 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.
34pub struct FrameLayout {
35 pub title: Rect,
36 pub separator: Rect,
37 pub body: Rect,
38 pub progress: Rect,
39 pub status: Rect,
40}
41
42impl 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.
63pub 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`.
111pub 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.
122pub 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.
151pub 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.
164pub 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 `> `.
175pub 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.
206pub 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.
225pub 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.
232pub 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.
240pub 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.
250pub 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.
269pub 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]`.
278pub 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.
325pub 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.
331pub 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}