diff --git a/src/view.rs b/src/view.rs index ae2fb01..176b3da 100644 --- a/src/view.rs +++ b/src/view.rs @@ -29,7 +29,9 @@ mod popup; mod system_info; pub use input::handle_input; -use now_playing::{AlbumArt, now_playing_width, render_now_playing}; +use now_playing::{ + AlbumArt, PLAYER_BAR_HEIGHT, now_playing_width, render_now_playing, render_player_bar, +}; use panel::{render_browser, render_group_editor, render_group_panel}; use popup::{ render_config_dialog, render_help, render_playback_menu, render_sleep_timer_picker, @@ -478,11 +480,18 @@ pub fn render_ui( if let Mode::SystemInfo(scroll) = &mut ui.mode { return render_system_info(state, theme, scroll, frame, body); } - // The panel is as wide on every tab of the browser. + // The browser takes the whole width, above a bar showing what is playing. + if let Mode::Browser(browser) = &mut ui.mode { + let [panel, player] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(PLAYER_BAR_HEIGHT)]) + .areas(body); + render_browser(state, group, symbols, theme, browser, frame, panel); + return render_player_bar(group, symbols, theme, frame, player); + } + let panel_help = match ui.mode { Mode::GroupPicker(_) => Some(GROUP_PANEL_HELP), Mode::GroupEditor(_) => Some(GROUP_EDITOR_HELP), - Mode::Browser(_) => Some(BROWSER_HELP), _ => None, }; let now_playing = match panel_help { @@ -504,9 +513,6 @@ pub fn render_ui( Mode::GroupEditor(editor) => { render_group_editor(state, symbols, theme, editor, frame, panel); } - Mode::Browser(browser) => { - render_browser(state, group, symbols, theme, browser, frame, panel); - } _ => {} } fits.then_some(rest) @@ -1044,9 +1050,12 @@ mod tests { .find(|row| row.contains("Contemplation")) .unwrap(); assert!(current.contains("▌ ⏵ Contemplation"), "{current}"); - assert!( - rows.iter() - .any(|row| row.contains("Boucle Infinie · Summit · 5:24")) + // Its artist, album and duration in columns on the same row + let columns: Vec<_> = current.split_whitespace().skip(3).collect(); + assert_eq!( + columns, + ["Boucle", "Infinie", "Summit", "5:24", "│"], + "{current}" ); assert!( !rows @@ -1096,6 +1105,58 @@ mod tests { assert!(!rows.iter().any(|row| row.contains("Escape"))); } + #[test] + fn browser_shows_what_is_playing_in_a_bar_below_it() { + use crate::sonos::{Source, TrackInfo}; + + let mut state = household_with_queue(); + let group = &mut state.groups[0]; + group.volume = Some(13); + group.now_playing = Some(TrackInfo { + title: Some("Nocturne".to_owned()), + artist: Some("TesseracT".to_owned()), + album_explicit: false, + duration_secs: Some(350), + image_url: None, + is_station: false, + }); + group.next_track = Some(TrackInfo { + title: Some("Exile".to_owned()), + ..group.now_playing.clone().unwrap() + }); + group.source = Some(Source { + service: Some("Spotify".to_owned()), + kind: Some("album".to_owned()), + name: Some("Altered State".to_owned()), + }); + let mut ui = UiState::default(); + ui.sync(&state); + press(&[KeyCode::Char('l')], &state, &mut ui); + + let rows = screen_rows(&state, &mut ui, 120); + let [.., info, progress, controls] = rows.as_slice() else { + panic!("{rows:?}"); + }; + assert!(info.starts_with(" Nocturne · TesseracT "), "{info}"); + assert!( + info.ends_with(" Spotify · Album · Altered State "), + "{info}" + ); + assert!(progress.contains("-5:50"), "{progress}"); + assert!(controls.starts_with(" shuffle prev"), "{controls}"); + assert!(controls.ends_with(" 13 "), "{controls}"); + assert!(!rows.iter().any(|row| row.contains("Exile"))); + + // Without room for the source, nor for the play modes beside the volume + let rows = screen_rows(&state, &mut ui, 40); + let [.., info, _, controls] = rows.as_slice() else { + panic!("{rows:?}"); + }; + assert!(!info.contains("Spotify"), "{info}"); + assert!(controls.starts_with(" prev"), "{controls}"); + assert!(controls.ends_with(" 13 "), "{controls}"); + } + #[test] fn browser_tabs_show_the_history_the_favorites_and_the_playlists() { let mut state = household(); diff --git a/src/view/now_playing.rs b/src/view/now_playing.rs index 43d0bad..82db115 100644 --- a/src/view/now_playing.rs +++ b/src/view/now_playing.rs @@ -5,7 +5,7 @@ use std::time::Instant; use ratatui::{ Frame, buffer::Buffer, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Layout, Margin, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Gauge, Paragraph, Widget}, @@ -30,6 +30,17 @@ const VOLUME_SIDE_WIDTH: u16 = 3; /// How wide the times on each side of the progress bar can be, e.g. `-59:59`. const TIME_WIDTH: u16 = 6; +/// How many lines the player bar takes. +pub(super) const PLAYER_BAR_HEIGHT: u16 = 3; + +/// How wide the volume is in the player bar, with its icon and value: as wide as there is room +/// for, within these bounds. The play modes are left out when it would be narrower. +const BAR_VOLUME_MIN_WIDTH: u16 = 16; +const BAR_VOLUME_MAX_WIDTH: u16 = 28; + +/// How far apart things are on a line of the player bar. +const BAR_GAP: u16 = 3; + /// What to show where the album art goes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum AlbumArt { @@ -148,12 +159,16 @@ pub(super) fn render_now_playing( // The volume slider lines up with the controls above it, unless they are too narrow (with // icons), and the progress bar with the slider. - let controls = controls_line(group, symbols, theme); + let controls = controls_line(group, symbols, theme, true); let volume_width = volume_width(&controls); - let controls_width = u16::try_from(controls.width()).unwrap_or(u16::MAX); + let controls_width = width(&controls); frame.render_widget(Paragraph::new(controls).centered(), controls_area); if let Some(end) = group.sleep_timer { - render_sleep_timer(end, symbols, theme, frame, controls_area, controls_width); + // Where `Paragraph::centered` puts the controls + let controls_end = controls_area.x + + controls_area.width.saturating_sub(controls_width) / 2 + + controls_width; + render_sleep_timer(end, symbols, theme, frame, controls_area, controls_end); } let slider_area = render_volume( group, @@ -196,12 +211,83 @@ pub(super) fn render_now_playing( } } +/// What `group` is playing, in a bar of [`PLAYER_BAR_HEIGHT`] lines: the track and where it comes +/// from, how far into it playback is, then the controls and the volume. +pub(super) fn render_player_bar( + group: &GroupState, + symbols: &Symbols, + theme: &Theme, + frame: &mut Frame, + area: Rect, +) { + let area = area.inner(Margin::new(1, 0)); + let [info_area, progress_area, controls_area] = + Layout::vertical([Constraint::Length(1); 3]).areas(area); + + // The track, with where it comes from on the right if there is room for both + let mut info = match &group.now_playing { + Some(track) => title_line(track, theme), + None => Line::styled("Nothing playing", Style::default().fg(theme.muted)), + }; + if let Some(track) = group.now_playing.as_ref().filter(|track| !track.is_station) { + info.push_span(Span::styled(" · ", Style::default().fg(theme.muted))); + info.push_span(Span::styled( + track.artist.as_deref().unwrap_or("Unknown artist"), + Style::default().fg(theme.artist), + )); + } + let source = group + .source + .as_ref() + .map(|source| source_line(source, symbols, theme)) + .unwrap_or_default(); + let source_width = width(&source); + if width(&info) + BAR_GAP + source_width <= info_area.width { + let x = info_area.right() - source_width; + let source_area = Rect { + x, + width: source_width, + ..info_area + }; + frame.render_widget(source, source_area); + } + frame.render_widget(info, info_area); + + let bar_area = progress_area.inner(Margin::new(TIME_WIDTH + 1, 0)); + render_progress(group, theme, frame, progress_area, bar_area); + + // The controls on the left, without the play modes if they leave too little room for the + // volume on the right + let mut controls = controls_line(group, symbols, theme, true); + if width(&controls) + BAR_GAP + BAR_VOLUME_MIN_WIDTH > controls_area.width { + controls = controls_line(group, symbols, theme, false); + } + let controls_width = width(&controls); + let volume_width = controls_area + .width + .saturating_sub(controls_width + BAR_GAP) + .min(BAR_VOLUME_MAX_WIDTH); + let [controls_area, volume_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(volume_width)]) + .spacing(BAR_GAP) + .areas(controls_area); + frame.render_widget(controls, controls_area); + if let Some(end) = group.sleep_timer { + let controls_end = controls_area.x + controls_width; + render_sleep_timer(end, symbols, theme, frame, controls_area, controls_end); + } + render_volume(group, symbols, theme, frame, volume_area); +} + +/// How wide `line` is, in columns. +fn width(line: &Line) -> u16 { + u16::try_from(line.width()).unwrap_or(u16::MAX) +} + /// How wide the volume is, with the slider between its icon and value: as wide as the /// `controls` above it, unless they are too narrow. fn volume_width(controls: &Line) -> u16 { - u16::try_from(controls.width()) - .unwrap_or(u16::MAX) - .max(MIN_VOLUME_WIDTH) + width(controls).max(MIN_VOLUME_WIDTH) } /// A square in the middle of `area`, with an icon in it, where the album art goes when there is @@ -228,7 +314,7 @@ fn render_art_placeholder( /// How wide what `group` is playing needs to be for the controls, the volume and the progress bar /// to fit. pub(super) fn now_playing_width(group: &GroupState, symbols: &Symbols, theme: &Theme) -> u16 { - let volume = volume_width(&controls_line(group, symbols, theme)); + let volume = volume_width(&controls_line(group, symbols, theme, true)); // The progress bar is as wide as the volume slider, with the times on each side of it. let bar = volume.saturating_sub(2 * (VOLUME_SIDE_WIDTH + 1)); volume.max(bar + 2 * (TIME_WIDTH + 1)) @@ -284,9 +370,14 @@ fn title_line<'a>(track: &'a TrackInfo, theme: &Theme) -> Line<'a> { Line::from(spans) } -/// The playback controls between the play modes, shuffle on the left and repeat on the right, -/// highlighting the playback status and the play modes that are on. -fn controls_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<'static> { +/// The playback controls, between the play modes if `play_modes`, shuffle on the left and repeat +/// on the right, highlighting the playback status and the play modes that are on. +fn controls_line( + group: &GroupState, + symbols: &Symbols, + theme: &Theme, + play_modes: bool, +) -> Line<'static> { let is_playing = group.playback.is_some_and(|p| p.is_playing); let status = match group.playback { _ if is_playing => symbols.playing, @@ -307,9 +398,12 @@ fn controls_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<' (repeat, modes.repeat || modes.repeat_one, Action::Repeat), ]; + let shown = items + .into_iter() + .filter(|(_, _, action)| play_modes || !matches!(action, Action::Shuffle | Action::Repeat)); let mut spans = Vec::new(); - for (i, (label, on, action)) in items.into_iter().enumerate() { - if i > 0 { + for (label, on, action) in shown { + if !spans.is_empty() { spans.push(Span::raw(" ")); } let style = match (supports(group, action), on) { @@ -479,15 +573,15 @@ pub(super) fn volume_text(volume: Option) -> String { volume.map_or_else(|| " --".to_owned(), |v| format!("{v:>3}")) } -/// The time left on a sleep timer running out at `end`, to the right of the controls, which are -/// `controls_width` wide in the middle of `area`, if there is room for it. +/// The time left on a sleep timer running out at `end`, to the right of the controls, which end at +/// `controls_end` in `area`, if there is room for it. fn render_sleep_timer( end: Instant, symbols: &Symbols, theme: &Theme, frame: &mut Frame, area: Rect, - controls_width: u16, + controls_end: u16, ) { // As far from the controls as they are from each other const GAP: u16 = 3; @@ -495,11 +589,14 @@ fn render_sleep_timer( format!("{} {}", symbols.sleep_timer, sleep_timer_text(end)), Style::default().fg(theme.on), ); - let width = u16::try_from(timer.width()).unwrap_or(u16::MAX); - // Where `Paragraph::centered` puts the controls - let x = area.x + area.width.saturating_sub(controls_width) / 2 + controls_width + GAP; - let timer_area = Rect { x, width, ..area }.intersection(area); - if timer_area.width == width { + let timer_width = width(&timer); + let timer_area = Rect { + x: controls_end + GAP, + width: timer_width, + ..area + } + .intersection(area); + if timer_area.width == timer_width { frame.render_widget(timer, timer_area); } } diff --git a/src/view/panel.rs b/src/view/panel.rs index d71e273..c9164d0 100644 --- a/src/view/panel.rs +++ b/src/view/panel.rs @@ -1,11 +1,12 @@ -//! The panel on the left, listing the groups, the speakers of one, or things played or to play. +//! Panels listing the groups or the speakers of one, on the left, or things played or to play. use ratatui::{ Frame, + buffer::Buffer, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, BorderType::Rounded, Padding, Paragraph}, + text::{Line, Span, Text}, + widgets::{Block, BorderType::Rounded, Padding, Paragraph, Widget}, }; use sinuous_client::{ @@ -44,7 +45,7 @@ pub(super) fn render_group_panel( .groups .iter() .enumerate() - .map(|(i, group)| group_lines(group, i == selected, symbols, theme)) + .map(|(i, group)| Text::from(Vec::from(group_lines(group, i == selected, symbols, theme)))) .collect(); render_panel( frame, @@ -53,23 +54,25 @@ pub(super) fn render_group_panel( Line::from(" Groups "), GROUP_PANEL_HELP, None, + GROUP_HEIGHT, items, highlight, ); } -/// A panel titled `title` in `area`, listing `items` a line apart, scrolled so that the one at -/// `highlight` is shown, and highlighted, with the keys to use (`help`) at the bottom, and `note` -/// above the items, if any. +/// A panel titled `title` in `area`, listing `items` of `item_height` lines, a line apart if they +/// take several, scrolled so that the one at `highlight` is shown, and highlighted, with the keys +/// to use (`help`) at the bottom, and `note` above the items, if any. #[expect(clippy::too_many_arguments)] -fn render_panel( +fn render_panel( frame: &mut Frame, theme: &Theme, area: Rect, title: Line, help: &str, note: Option, - items: Vec<[Line; N]>, + item_height: u16, + items: Vec, highlight: &mut PanelHighlight, ) { let help = Line::from(help) @@ -79,7 +82,8 @@ fn render_panel( .border_type(Rounded) .border_style(Style::default().fg(theme.border)) .style(theme.popup()) - .padding(Padding::vertical(1)) + // Off the border on the right too, as the items are after the bar on the left + .padding(Padding::new(0, 1, 1, 1)) .title_top(title) .title_bottom(help); let mut inner = block.inner(area); @@ -98,17 +102,17 @@ fn render_panel( inner = rest; } - // Items are a line apart, and scrolled so that the highlighted one is shown. - let item_height = u16::try_from(N).unwrap_or(u16::MAX); - let shown = usize::from((inner.height + 1) / (item_height + 1)).max(1); + // Items are scrolled so that the highlighted one is shown. + let gap = u16::from(item_height > 1); + let shown = usize::from((inner.height + gap) / (item_height + gap)).max(1); highlight.scroll(shown); let PanelHighlight { item: highlighted, first, .. } = *highlight; - let rows = (inner.y..).step_by(usize::from(item_height + 1)); - for ((i, lines), y) in items.into_iter().enumerate().skip(first).zip(rows) { + let rows = (inner.y..).step_by(usize::from(item_height + gap)); + for ((i, item), y) in items.into_iter().enumerate().skip(first).zip(rows) { let item_area = Rect { y, height: item_height, @@ -138,7 +142,23 @@ fn render_panel( bar_area, ); } - frame.render_widget(Paragraph::new(Vec::from(lines)), lines_area); + frame.render_widget(item, lines_area); + } +} + +/// Lines side by side, in columns as wide as `widths`, so that the lines of the items of a list +/// line up. +struct Columns<'a> { + cells: Vec>, + widths: &'static [Constraint], +} + +impl Widget for Columns<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let areas = Layout::horizontal(self.widths).spacing(2).split(area); + for (cell, area) in self.cells.into_iter().zip(areas.iter()) { + cell.render(*area, buf); + } } } @@ -224,7 +244,11 @@ pub(super) fn render_group_editor( let items = state .players .iter() - .map(|player| speaker_lines(state, player, group, editor, symbols, theme)) + .map(|player| { + Text::from(Vec::from(speaker_lines( + state, player, group, editor, symbols, theme, + ))) + }) .collect(); render_panel( frame, @@ -233,6 +257,7 @@ pub(super) fn render_group_editor( Line::from(" Group speakers "), GROUP_EDITOR_HELP, None, + 2, items, &mut editor.highlight, ); @@ -342,7 +367,7 @@ pub(super) fn render_browser( let items = queue .items .iter() - .map(|entry| queue_entry_lines(entry, current, symbols, theme)) + .map(|entry| queue_entry_row(entry, current, symbols, theme)) .collect(); // The queue keeps what was last played from it. let note = current.is_none().then_some("Not playing from the queue"); @@ -358,30 +383,23 @@ pub(super) fn render_browser( }; (help, note, items) } - // Its items are a line each, unlike the other tabs'. Tab::History => { let items: Vec<_> = state .history .iter() - .map(|item| [history_line(item, symbols, theme)]) + .map(|item| Columns { + cells: vec![history_line(item, symbols, theme)], + widths: &[Constraint::Fill(1)], + }) .collect(); let note = items.is_empty().then_some("Nothing played recently"); - return render_panel( - frame, - theme, - area, - title, - BROWSER_HELP, - note.map(|note| Line::styled(note, muted)), - items, - &mut browser.highlight, - ); + (BROWSER_HELP, note, items) } Tab::Favorites => { let items: Vec<_> = state .favorites .iter() - .map(|favorite| favorite_lines(favorite, symbols, theme)) + .map(|favorite| favorite_row(favorite, symbols, theme)) .collect(); let note = items.is_empty().then_some("No favorites"); (BROWSER_HELP, note, items) @@ -390,7 +408,7 @@ pub(super) fn render_browser( let items: Vec<_> = state .playlists .iter() - .map(|playlist| playlist_lines(playlist, theme)) + .map(|playlist| playlist_row(playlist, theme)) .collect(); let note = items.is_empty().then_some("No playlists"); (BROWSER_HELP, note, items) @@ -403,52 +421,60 @@ pub(super) fn render_browser( title, help, note.map(|note| Line::styled(note, muted)), + 1, items, &mut browser.highlight, ); } -/// The lines showing `entry` of a queue: its title, then its artist, album and duration. The -/// track at position `current`, if any, is marked as playing. -fn queue_entry_lines<'a>( +/// The row showing `entry` of a queue: its title, artist, album and duration. The track at +/// position `current`, if any, is marked as playing. +fn queue_entry_row<'a>( entry: &'a QueueEntry, current: Option, symbols: &Symbols, theme: &Theme, -) -> [Line<'a>; 2] { - let muted = Style::default().fg(theme.muted); +) -> Columns<'a> { let mut title_style = Style::default().add_modifier(Modifier::BOLD); - let mut title_line = Vec::new(); + let mut title = Vec::new(); if current == Some(entry.position) { title_style = title_style.fg(theme.name); - title_line.push(Span::styled( + title.push(Span::styled( format!("{} ", symbols.playing), Style::default().fg(theme.on), )); } - title_line.push(Span::styled( + title.push(Span::styled( entry.name.as_deref().unwrap_or("Unknown title"), title_style, )); - let mut details = vec![Span::styled( + let artist = Line::styled( entry.artist.as_deref().unwrap_or("Unknown artist"), Style::default().fg(theme.artist), - )]; - if let Some(album) = &entry.album { - details.push(Span::styled(" · ", muted)); - details.push(Span::styled( - album.as_str(), - Style::default().fg(theme.secondary), - )); - } - if let Some(millis) = entry.duration_millis { + ); + let album = Line::styled( + entry.album.as_deref().unwrap_or_default(), + Style::default().fg(theme.secondary), + ); + let duration = entry.duration_millis.map(|millis| { let secs = u32::try_from(millis / 1000).unwrap_or(u32::MAX); - details.push(Span::styled(" · ", muted)); - details.push(Span::styled(format_duration(secs), muted)); + Line::styled(format_duration(secs), Style::default().fg(theme.muted)).right_aligned() + }); + Columns { + cells: vec![ + Line::from(title), + artist, + album, + duration.unwrap_or_default(), + ], + widths: &[ + Constraint::Fill(3), + Constraint::Fill(2), + Constraint::Fill(2), + Constraint::Length(6), + ], } - - [Line::from(title_line), Line::from(details)] } /// The line showing `item` of the history: what it is, then its name, muted if it can't be @@ -487,27 +513,30 @@ fn kind_spans(kind: Option<&'static str>, symbols: &Symbols, theme: &Theme) -> V .unwrap_or_default() } -/// The lines showing `playlist`: its name, then how many tracks it has. -fn playlist_lines<'a>(playlist: &'a PlaylistSummary, theme: &Theme) -> [Line<'a>; 2] { +/// The row showing `playlist`: its name and how many tracks it has. +fn playlist_row<'a>(playlist: &'a PlaylistSummary, theme: &Theme) -> Columns<'a> { let tracks = playlist.track_count.map(|count| { let tracks = if count == 1 { "track" } else { "tracks" }; Line::styled( format!("{count} {tracks}"), Style::default().fg(theme.muted), ) + .right_aligned() }); - [ - Line::styled( - playlist.name.as_str(), - Style::default().add_modifier(Modifier::BOLD), - ), - tracks.unwrap_or_default(), - ] + Columns { + cells: vec![ + Line::styled( + playlist.name.as_str(), + Style::default().add_modifier(Modifier::BOLD), + ), + tracks.unwrap_or_default(), + ], + widths: &[Constraint::Fill(1), Constraint::Length(12)], + } } -/// The lines showing `favorite`: what it is and its name, then its music service and its -/// description. -fn favorite_lines<'a>(favorite: &'a Favorite, symbols: &Symbols, theme: &Theme) -> [Line<'a>; 2] { +/// The row showing `favorite`: what it is and its name, and its music service and its description. +fn favorite_row<'a>(favorite: &'a Favorite, symbols: &Symbols, theme: &Theme) -> Columns<'a> { let muted = Style::default().fg(theme.muted); let service = favorite .service @@ -549,5 +578,8 @@ fn favorite_lines<'a>(favorite: &'a Favorite, symbols: &Symbols, theme: &Theme) favorite.name.as_str(), Style::default().add_modifier(Modifier::BOLD), )); - [Line::from(name_line), Line::from(details)] + Columns { + cells: vec![Line::from(name_line), Line::from(details)], + widths: &[Constraint::Fill(1), Constraint::Fill(1)], + } }