diff --git a/src/view.rs b/src/view.rs index 5c047eb..33db122 100644 --- a/src/view.rs +++ b/src/view.rs @@ -296,9 +296,7 @@ pub fn is_animated(state: &HouseholdState, ui: &UiState) -> bool { let advancing = group .and_then(|group| group.playback) .is_some_and(|playback| playback.is_advancing()); - // The playback menu shows the time left on the sleep timer. - let counting_down = matches!(ui.mode, Mode::PlaybackMenu { .. }) - && group.is_some_and(|group| group.sleep_timer.is_some()); + let counting_down = group.is_some_and(|group| group.sleep_timer.is_some()); showing_error || advancing || counting_down } @@ -754,6 +752,54 @@ mod tests { assert_eq!(slider_start("Office Move"), group); } + #[test] + fn sleep_timer_shows_beside_the_controls_when_there_is_room() { + use ratatui::{Terminal, backend::TestBackend}; + + let controls_row = |state: &HouseholdState, width| { + let mut ui = UiState::default(); + ui.sync(state); + let mut terminal = Terminal::new(TestBackend::new(width, 30)).unwrap(); + let (mut artwork, _) = Artwork::new(ratatui_image::picker::Picker::halfblocks(), None); + terminal + .draw(|f| { + render_ui( + f, + state, + &mut ui, + &mut artwork, + &UiConfig::default(), + &Theme::TERMINAL, + ); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .find(|row| row.contains("shuffle")) + .unwrap() + }; + let without = household(); + let mut with = household(); + with.groups[0].sleep_timer = + Some(std::time::Instant::now() + Duration::from_secs(20 * 60 + 30)); + + let row = controls_row(&with, 80); + let timer = row.find("🌙").expect(&row); + assert!(row[timer..].contains("20:2"), "{row}"); + // The controls don't move to make room for it. + let plain = controls_row(&without, 80); + assert_eq!(row[..timer].trim_end(), plain.trim_end(), "{row}"); + + // There is no room for it beside the controls. + let narrow = controls_row(&with, 44); + assert!(!narrow.contains("🌙"), "{narrow}"); + } + #[test] fn panel_scrolls_as_little_as_possible() { let mut highlight = PanelHighlight::new(0); diff --git a/src/view/now_playing.rs b/src/view/now_playing.rs index 5ba4878..1252e04 100644 --- a/src/view/now_playing.rs +++ b/src/view/now_playing.rs @@ -148,7 +148,11 @@ pub(super) fn render_now_playing( // icons), and the progress bar with the slider. let controls = controls_line(group, symbols, theme); let volume_width = volume_width(&controls); + let controls_width = u16::try_from(controls.width()).unwrap_or(u16::MAX); 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); + } let slider_area = render_volume( group, symbols, @@ -450,6 +454,43 @@ 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. +fn render_sleep_timer( + end: Instant, + symbols: &Symbols, + theme: &Theme, + frame: &mut Frame, + area: Rect, + controls_width: u16, +) { + // As far from the controls as they are from each other + const GAP: u16 = 3; + let timer = Line::styled( + 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 { + frame.render_widget(timer, timer_area); + } +} + +/// The time left on a sleep timer running out at `end`: hours, minutes and seconds, or minutes +/// and seconds if under an hour. +pub(super) fn sleep_timer_text(end: Instant) -> String { + let secs = end.saturating_duration_since(Instant::now()).as_secs(); + let (hours, minutes, seconds) = (secs / 3600, secs / 60 % 60, secs % 60); + if hours > 0 { + format!("{hours}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes}:{seconds:02}") + } +} + fn format_duration(secs: u32) -> String { let minutes = secs / 60; let seconds = secs % 60; diff --git a/src/view/popup.rs b/src/view/popup.rs index b2536e8..460abbd 100644 --- a/src/view/popup.rs +++ b/src/view/popup.rs @@ -1,7 +1,5 @@ //! The popups shown in the middle of the screen. -use std::time::{Duration, Instant}; - use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -16,7 +14,7 @@ use crate::symbols::Symbols; use crate::theme::{self, THEMES, Theme}; use super::input::{Action, supports}; -use super::now_playing::{volume_slider, volume_text}; +use super::now_playing::{sleep_timer_text, volume_slider, volume_text}; use super::panel::speaker_name; use super::{PlaybackItem, SLEEP_TIMER_MINUTES}; @@ -169,10 +167,7 @@ fn sleep_timer_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Lin .saturating_sub(Span::raw(symbols.sleep_timer).width()); let value = match group.sleep_timer { Some(end) => Span::styled( - format!( - "{} left", - time_left(end.saturating_duration_since(Instant::now())) - ), + format!("{} left", sleep_timer_text(end)), Style::default().fg(theme.name), ), None => Span::styled("off", Style::default().fg(theme.secondary)), @@ -185,17 +180,6 @@ fn sleep_timer_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Lin ]) } -/// `time` as hours, minutes and seconds, or minutes and seconds if under an hour. -fn time_left(time: Duration) -> String { - let secs = time.as_secs(); - let (hours, minutes, seconds) = (secs / 3600, secs / 60 % 60, secs % 60); - if hours > 0 { - format!("{hours}:{minutes:02}:{seconds:02}") - } else { - format!("{minutes}:{seconds:02}") - } -} - /// A popup listing what the sleep timer can be set to, with the choice at `highlighted` /// highlighted. pub(super) fn render_sleep_timer_picker(theme: &Theme, highlighted: usize, frame: &mut Frame) {