From 53e3dc59f872925c9614c3bb968ad828808e14ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Fri, 16 Oct 2026 17:09:27 +1100 Subject: [PATCH] feat: Add a system info screen, with the details of each speaker Press i to open it. It shows the household, and for each speaker its group, playback state, volume, address, ID, versions, registration and capabilities, then the model, serial number and versions of each of its devices. It is meant for troubleshooting. Its labels use a new label colour, to stand out from their values. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 1 + src/sonos.rs | 19 +- src/theme.rs | 4 + src/view.rs | 24 ++- src/view/input.rs | 29 ++- src/view/popup.rs | 1 + src/view/system_info.rs | 415 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 487 insertions(+), 10 deletions(-) create mode 100644 src/view/system_info.rs diff --git a/Cargo.lock b/Cargo.lock index 7f77fde..0f81f81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2723,6 +2723,7 @@ dependencies = [ "reqwest", "rustls", "serde", + "serde_json", "sha2 0.11.1", "sinuous-client", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5a00bed..5fc266c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,6 @@ url = "2" [profile.release] strip = "symbols" + +[dev-dependencies] +serde_json = "1.0.154" diff --git a/README.md b/README.md index 53cc7a7..12bab72 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ sinuous --device "Living Room" | g | Switch to another group, from a panel showing what each group is playing | | G | Add speakers to or remove speakers from the group | | f | Play one of your Sonos favorites | +| i | System info: the details of each speaker, for troubleshooting | | , | Settings | | ? | Keyboard shortcuts | | q | Close what is open, or quit | diff --git a/src/sonos.rs b/src/sonos.rs index cac4766..851f252 100644 --- a/src/sonos.rs +++ b/src/sonos.rs @@ -14,7 +14,8 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use futures::StreamExt; use sinuous_client::{ - Event, EventPayload, FavoriteId, GroupId, Household, PlayerId, Subscription, Topology, + Event, EventPayload, FavoriteId, GroupId, Household, HouseholdId, PlayerId, Subscription, + Topology, favorites::Favorite, playback::{ LoadAction, LoadOptions, PlayModes, PlayModesUpdate, PlaybackActions, PlaybackState, @@ -63,6 +64,9 @@ impl ProvidedDevices { /// What the service knows about the household. #[derive(Debug, Clone, Default)] pub struct HouseholdState { + pub household_id: Option, + /// The groups and players as the household last reported them, with all their details. + pub topology: Arc, /// Sorted by name. pub groups: Vec, /// Sorted by name. @@ -147,6 +151,7 @@ pub struct PlayerInfo { #[derive(Debug, Clone, Copy)] pub struct Playback { + pub state: PlaybackState, pub is_playing: bool, pub play_modes: PlayModes, /// What the current source supports. @@ -173,6 +178,7 @@ impl Playback { fn from_parts(state: PlaybackState, position: Duration, now: Instant) -> Self { Self { + state, is_playing: matches!(state, PlaybackState::Playing | PlaybackState::Buffering), play_modes: PlayModes::default(), actions: PlaybackActions::default(), @@ -368,7 +374,10 @@ impl SonosHandle { /// /// The groups and speakers of the household are in the state right away. pub fn spawn(household: Household) -> SonosHandle { - let (state_tx, state_rx) = watch::channel(HouseholdState::default()); + let (state_tx, state_rx) = watch::channel(HouseholdState { + household_id: Some(household.id().clone()), + ..HouseholdState::default() + }); let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_QUEUE_SIZE); tokio::spawn(run_commands(household.clone(), cmd_rx, state_tx.clone())); @@ -536,11 +545,13 @@ impl Service { /// Update the groups and players from the household's topology, keeping what we know about /// them. fn update_groups(&mut self) { - let (groups, players) = { + let (topology, groups, players) = { let topology = self.topology_rx.borrow_and_update(); - (group_states(&topology), player_infos(&topology)) + let (groups, players) = (group_states(&topology), player_infos(&topology)); + (Arc::new(topology.clone()), groups, players) }; self.state_tx.send_modify(|state| { + state.topology = topology; let old_groups = std::mem::replace(&mut state.groups, groups); keep_group_states(&mut state.groups, &old_groups); let old_players = std::mem::replace(&mut state.players, players); diff --git a/src/theme.rs b/src/theme.rs index 8202d91..a3f2098 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -57,6 +57,8 @@ pub struct Theme { pub text: Color, /// Less important text, e.g. labels. pub secondary: Color, + /// Labels standing out from their values, where both matter, e.g. in the system info. + pub label: Color, /// Unimportant text, e.g. separators, and actions that aren't supported. pub muted: Color, /// The name of the app, and titles. @@ -88,6 +90,7 @@ impl Theme { selection: None, text: Color::Reset, secondary: Color::Gray, + label: Color::Cyan, muted: Color::DarkGray, title: Color::Yellow, title_background: Color::DarkGray, @@ -116,6 +119,7 @@ impl Theme { selection: Some(color(tokens::BG_SELECTION)), text: color(tokens::TEXT_PRIMARY), secondary: color(tokens::TEXT_SECONDARY), + label: color(tokens::ACCENT_SECONDARY), muted: color(tokens::TEXT_DIM), title: color(tokens::ACCENT_PRIMARY), title_background: color(tokens::BG_HIGHLIGHT), diff --git a/src/view.rs b/src/view.rs index ef72ec5..7de51b0 100644 --- a/src/view.rs +++ b/src/view.rs @@ -21,6 +21,7 @@ mod input; mod now_playing; mod panel; mod popup; +mod system_info; pub use input::handle_input; use now_playing::{AlbumArt, now_playing_width, render_now_playing}; @@ -29,6 +30,7 @@ use popup::{ render_config_dialog, render_favorites_picker, render_help, render_playback_menu, render_theme_picker, render_volume_mixer, }; +use system_info::render_system_info; /// How long errors are displayed for. const ERROR_DISPLAY_TIME: Duration = Duration::from_secs(5); @@ -68,6 +70,8 @@ enum Mode { PlaybackMenu(usize), /// The keyboard shortcuts. Help, + /// The details of each speaker, in place of everything but the title bar. + SystemInfo(Scroll), /// The configuration dialog, and the theme picker on top of it, if open. Config { /// The setting highlighted: one of [`UiSetting::ALL`], or the theme after them. @@ -145,6 +149,15 @@ impl GroupEditor { } } +/// How far a fullscreen view is scrolled. +#[derive(Debug, Clone, Copy, Default)] +struct Scroll { + /// The first line shown. + offset: usize, + /// How many lines were shown last time, to scroll by pages. + page: usize, +} + /// The highlighted item of a list in the panel on the left, and the first one shown. #[derive(Debug, Clone, Copy)] struct PanelHighlight { @@ -200,16 +213,16 @@ impl UiState { Some(rows) => clamp(highlighted, rows), None => self.mode = Mode::NowPlaying, }, - Mode::NowPlaying | Mode::Help | Mode::Config { .. } => {} + Mode::NowPlaying | Mode::Help | Mode::SystemInfo(_) | Mode::Config { .. } => {} } } /// Whether a popup is open in the middle of the screen. The group panel and the group editor - /// are beside what is playing, not on top of it. + /// are beside what is playing, not on top of it, and the system info takes its place. fn popup_open(&self) -> bool { !matches!( self.mode, - Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) + Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) ) } @@ -285,6 +298,9 @@ pub fn render_ui( Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(frame.area()); render_title_bar(state, ui, group, symbols, theme, frame, title); + if let Mode::SystemInfo(scroll) = &mut ui.mode { + return render_system_info(state, theme, scroll, frame, body); + } let panel_help = match ui.mode { Mode::GroupPicker(_) => Some(GROUP_PANEL_HELP), Mode::GroupEditor(_) => Some(GROUP_EDITOR_HELP), @@ -346,7 +362,7 @@ pub fn render_ui( render_theme_picker(&settings.theme, theme, highlighted, frame); } } - Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) => {} + Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) => {} } } diff --git a/src/view/input.rs b/src/view/input.rs index 20b050d..599cc10 100644 --- a/src/view/input.rs +++ b/src/view/input.rs @@ -12,7 +12,7 @@ use crate::config::{UiConfig, UiSetting}; use crate::sonos::{Command, GroupAction, GroupState, HouseholdState, PlayerAction}; use crate::theme::THEMES; -use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Selection, UiState}; +use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Scroll, Selection, UiState}; /// How much the volume changes with each key press. const VOLUME_STEP: i8 = 1; @@ -55,6 +55,10 @@ pub fn handle_input( Mode::PlaybackMenu(highlighted) => { handle_playback_menu_input(input, state, ui, highlighted) } + Mode::SystemInfo(scroll) => { + handle_system_info_input(input, ui, scroll); + None + } Mode::Help => { if matches!(input.code, KeyCode::Esc | KeyCode::Char('?')) { ui.mode = Mode::NowPlaying; @@ -93,6 +97,7 @@ fn handle_now_playing_input( KeyCode::Char('.') => Mode::PlaybackMenu(0), KeyCode::Char('?') => Mode::Help, + KeyCode::Char('i') => Mode::SystemInfo(Scroll::default()), KeyCode::Char(',') => Mode::Config { highlighted: 0, theme_picker: None, @@ -169,6 +174,26 @@ fn handle_theme_picker_input( None } +/// Update `ui` according to the key pressed while the system info is open, scrolled to `scroll`. +fn handle_system_info_input(input: &KeyEvent, ui: &mut UiState, scroll: Scroll) { + // Scrolling past the end is undone when drawing it, which is when its length is known. + let page = scroll.page.max(1); + let offset = match input.code { + KeyCode::Up | KeyCode::Char('k') => scroll.offset.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => scroll.offset.saturating_add(1), + KeyCode::PageUp => scroll.offset.saturating_sub(page), + KeyCode::PageDown => scroll.offset.saturating_add(page), + KeyCode::Home => 0, + KeyCode::End => usize::MAX, + KeyCode::Esc | KeyCode::Char('i') => { + ui.mode = Mode::NowPlaying; + return; + } + _ => return, + }; + ui.mode = Mode::SystemInfo(Scroll { offset, ..scroll }); +} + /// Update `ui` according to the key pressed while the group picker is open, with the group at /// `highlight` highlighted. fn handle_group_picker_input( @@ -618,7 +643,7 @@ mod tests { let mut ui = UiState::default(); ui.sync(&state); - for opening in [',', 'v', '.', 'f', 'g', 'G', '?'] { + for opening in [',', 'v', '.', 'f', 'g', 'G', 'i', '?'] { assert!(press(&[KeyCode::Char(opening)], &state, &mut ui).is_none()); assert!(!matches!(ui.mode, Mode::NowPlaying), "{opening}"); assert!(press(&[KeyCode::Char('q')], &state, &mut ui).is_none()); diff --git a/src/view/popup.rs b/src/view/popup.rs index 5223dca..b03d060 100644 --- a/src/view/popup.rs +++ b/src/view/popup.rs @@ -180,6 +180,7 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) { ("f", "Play a favorite"), ("g", "Switch to another group"), ("G", "Add or remove speakers from the group"), + ("i", "System info, for troubleshooting"), (",", "Configuration"), ("?", "This help"), ("q", "Close what is open, or quit"), diff --git a/src/view/system_info.rs b/src/view/system_info.rs new file mode 100644 index 0000000..e116b30 --- /dev/null +++ b/src/view/system_info.rs @@ -0,0 +1,415 @@ +//! The system info: the details of each speaker, for troubleshooting. + +use ratatui::{ + Frame, + layout::{Margin, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{ + Block, BorderType::Rounded, Padding, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, + }, +}; +use sinuous_client::{ + groups::{Capability, DeviceInfo, Player}, + playback::PlaybackState, +}; + +use crate::sonos::{HouseholdState, PlayerInfo}; +use crate::theme::Theme; + +use super::Scroll; + +/// The keys to use in the system info. +const HELP: &str = " ↑↓ scroll • PGUP/PGDN page • ESC close "; + +/// How wide labels are, for the values to line up. +const LABEL_WIDTH: usize = 14; + +/// How far the details of the devices of a speaker are indented. +const DEVICE_INDENT: usize = 2; + +/// The details of the household and of each of its speakers, in `area`, scrolled to `scroll`. +pub(super) fn render_system_info( + state: &HouseholdState, + theme: &Theme, + scroll: &mut Scroll, + frame: &mut Frame, + area: Rect, +) { + let block = Block::bordered() + .border_type(Rounded) + .border_style(Style::default().fg(theme.border)) + .padding(Padding::horizontal(1)) + .title_top(" System info ") + .title_bottom( + Line::from(HELP) + .centered() + .style(Style::default().fg(theme.muted)), + ); + let inner = block.inner(area); + let lines = system_info_lines(state, theme, usize::from(inner.width)); + + // Don't scroll past the last line. + let len = lines.len(); + scroll.page = usize::from(inner.height); + let max_offset = len.saturating_sub(scroll.page); + scroll.offset = scroll.offset.min(max_offset); + + let offset = u16::try_from(scroll.offset).unwrap_or(u16::MAX); + frame.render_widget(Paragraph::new(lines).block(block).scroll((offset, 0)), area); + if max_offset > 0 { + let mut scrollbar = ScrollbarState::new(max_offset).position(scroll.offset); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_style(Style::default().fg(theme.border)) + .thumb_style(Style::default().fg(theme.secondary)), + area.inner(Margin::new(0, 1)), + &mut scrollbar, + ); + } +} + +/// The lines to show, with long lists wrapped to fit in `width`. +fn system_info_lines(state: &HouseholdState, theme: &Theme, width: usize) -> Vec> { + let rows = Rows { theme, width }; + let household = state + .household_id + .as_ref() + .map_or_else(|| "unknown".to_owned(), ToString::to_string); + let mut lines = vec![ + rows.row(0, "Household", household), + rows.row(0, "Speakers", state.players.len().to_string()), + rows.row(0, "Groups", state.groups.len().to_string()), + ]; + for player in &state.players { + lines.push(Line::default()); + lines.extend(player_lines(state, player, &rows)); + } + lines +} + +/// The details of `player`, and of each of its devices. +fn player_lines(state: &HouseholdState, player: &PlayerInfo, rows: &Rows) -> Vec> { + let theme = rows.theme; + let mut lines = vec![Line::styled( + player.name.clone(), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + )]; + + if let Some(group) = state + .groups + .iter() + .find(|g| g.player_ids.contains(&player.id)) + { + let mut value = group.name.clone(); + if group.coordinator_id == player.id { + value.push_str(" · coordinator"); + } + if let Some(playback) = group.playback { + value.push_str(" · "); + value.push_str(playback_state(playback.state)); + } + lines.push(rows.row(0, "Group", value)); + } + let volume = player + .volume + .map_or_else(|| "unknown".to_owned(), |volume| volume.to_string()); + lines.push(rows.row(0, "Volume", volume)); + + let Some(details) = state.topology.players.get(&player.id) else { + return lines; + }; + lines.extend([ + rows.row(0, "Address", address(&details.websocket_url)), + rows.row(0, "ID", details.id.to_string()), + rows.row(0, "Software", details.software_version.clone()), + rows.row( + 0, + "API version", + format!("{} (min {})", details.api_version, details.min_api_version), + ), + ]); + let registered = if details.is_unregistered { + Span::styled("no", Style::default().fg(theme.error)) + } else { + Span::raw("yes") + }; + lines.push(rows.row(0, "Registered", registered)); + lines.extend(rows.list(0, "Capabilities", capabilities(details.capabilities.iter()))); + + for device in &details.devices { + lines.extend(device_lines(details, device, rows)); + } + lines +} + +/// The details of `device`, one of the devices making up `player`. +fn device_lines(player: &Player, device: &DeviceInfo, rows: &Rows) -> Vec> { + let mut lines = vec![ + rows.row( + 0, + "Device", + Span::styled( + device.model_display_name.clone(), + Style::default().fg(rows.theme.name), + ), + ), + rows.row(DEVICE_INDENT, "Model", device.model.clone()), + ]; + // The main device of a player has its ID and address. + if device.id != player.id { + lines.push(rows.row(DEVICE_INDENT, "ID", device.id.to_string())); + lines.push(rows.row(DEVICE_INDENT, "Address", address(&device.websocket_url))); + } + lines.extend([ + rows.row(DEVICE_INDENT, "Serial", device.serial_number.clone()), + rows.row(DEVICE_INDENT, "Hardware", device.hw_version.clone()), + rows.row( + DEVICE_INDENT, + "Software", + format!("{} (Sonos S{})", device.software_version, device.sw_gen), + ), + ]); + if let Some(color) = &device.color { + lines.push(rows.row(DEVICE_INDENT, "Colour", color.clone())); + } + lines +} + +/// Lays out labelled values. +struct Rows<'a> { + theme: &'a Theme, + /// How wide lines may be. + width: usize, +} + +impl Rows<'_> { + /// `value`, labelled with `label`, indented by `indent`. + fn row(&self, indent: usize, label: &str, value: impl Into>) -> Line<'static> { + let label = format!( + "{}{label:) -> Vec> { + let available = self.width.saturating_sub(LABEL_WIDTH).max(1); + let mut values: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let item = if i + 1 < items.len() { + format!("{item},") + } else { + (*item).to_owned() + }; + match values.last_mut() { + Some(line) if line.len() + 1 + item.len() <= available => { + line.push(' '); + line.push_str(&item); + } + _ => values.push(item), + } + } + if values.is_empty() { + values.push("none".to_owned()); + } + values + .into_iter() + .enumerate() + .map(|(i, value)| self.row(indent, if i == 0 { label } else { "" }, value)) + .collect() + } +} + +/// The IP address (or host name) of a speaker, from the URL of its websocket. +fn address(websocket_url: &url::Url) -> String { + websocket_url + .host_str() + .map_or_else(|| websocket_url.to_string(), ToOwned::to_owned) +} + +fn playback_state(state: PlaybackState) -> &'static str { + match state { + PlaybackState::Buffering => "buffering", + PlaybackState::Idle => "idle", + PlaybackState::Paused => "paused", + PlaybackState::Playing => "playing", + PlaybackState::Unknown => "unknown", + } +} + +/// The names of `capabilities`, as the Sonos API calls them, to be looked up in its documentation. +fn capabilities(capabilities: impl Iterator) -> Vec<&'static str> { + capabilities + .map(|capability| match capability { + Capability::Playback => "PLAYBACK", + Capability::Cloud => "CLOUD", + Capability::HtPlayback => "HT_PLAYBACK", + Capability::HtPowerState => "HT_POWER_STATE", + Capability::Airplay => "AIRPLAY", + Capability::LineIn => "LINE_IN", + Capability::AudioClip => "AUDIO_CLIP", + Capability::Voice => "VOICE", + Capability::SpeakerDetection => "SPEAKER_DETECTION", + Capability::FixedVolume => "FIXED_VOLUME", + Capability::MicrophoneSwitch => "MICROPHONE_SWITCH", + Capability::IrControl => "IR_CONTROL", + Capability::Hdmi => "HDMI", + Capability::Svc => "SVC", + Capability::Unknown => "unknown", + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crossterm::event::KeyCode; + use ratatui::{Terminal, backend::TestBackend}; + use sinuous_client::Topology; + + use super::*; + use crate::artwork::Artwork; + use crate::config::UiConfig; + use crate::view::tests::{household, press}; + use crate::view::{UiState, render_ui}; + + /// The bedroom, as a soundbar with a sub. + fn bedroom() -> Player { + let device = |id: &str, model: &str, name: &str, ip: &str| { + serde_json::json!({ + "_objectType": "deviceInfo", + "id": id, + "name": "Bedroom", + "serialNumber": format!("{id}-serial"), + "model": model, + "modelDisplayName": name, + "color": "Black", + "capabilities": ["PLAYBACK"], + "apiVersion": "1.44.1", + "minApiVersion": "1.1.0", + "websocketUrl": format!("wss://{ip}:1443/websocket/api"), + "softwareVersion": "85.0-65270", + "hwVersion": "1.36.1.2-1.0", + "swGen": 2, + }) + }; + serde_json::from_value(serde_json::json!({ + "_objectType": "player", + "id": "b", + "name": "Bedroom", + "websocketUrl": "wss://10.0.0.2:1443/websocket/api", + "apiVersion": "1.44.1", + "minApiVersion": "1.1.0", + "softwareVersion": "85.0-65270", + "capabilities": [ + "PLAYBACK", "CLOUD", "HT_PLAYBACK", "HT_POWER_STATE", "AIRPLAY", + "AUDIO_CLIP", "VOICE", "SPEAKER_DETECTION", "FIXED_VOLUME", + ], + "isUnregistered": false, + "deviceIds": ["b", "sub"], + "devices": [ + device("b", "S45", "Arc Ultra", "10.0.0.2"), + device("sub", "S41", "Sub 4", "10.0.0.3"), + ], + })) + .unwrap() + } + + fn screen(state: &HouseholdState, ui: &mut UiState, width: u16, height: u16) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let (mut artwork, _) = Artwork::new(ratatui_image::picker::Picker::halfblocks(), None); + terminal + .draw(|f| { + render_ui( + f, + state, + 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() + }) + .collect() + } + + fn state() -> HouseholdState { + let mut topology = Topology::default(); + topology.players.insert_overwrite(bedroom()); + HouseholdState { + household_id: Some("Sonos_1".into()), + topology: Arc::new(topology), + ..household() + } + } + + #[test] + fn shows_the_details_of_speakers_and_their_devices() { + let state = state(); + let mut ui = UiState::default(); + ui.sync(&state); + press(&[KeyCode::Char('i')], &state, &mut ui); + + let screen = screen(&state, &mut ui, 60, 60).join("\n"); + for shown in [ + "Household Sonos_1", + "Group Bedroom + Office · coordinator", + "Address 10.0.0.2", + "Device Arc Ultra", + " Model S45", + "Device Sub 4", + " Address 10.0.0.3", + " Software 85.0-65270 (Sonos S2)", + // Speakers the topology doesn't know about yet still show what is known. + "Kitchen", + ] { + assert!(screen.contains(shown), "{shown}\n{screen}"); + } + // The capabilities are wrapped, and lined up. + assert!( + screen.contains("Capabilities PLAYBACK, CLOUD,"), + "{screen}" + ); + assert!( + screen.contains(" VOICE, SPEAKER_DETECTION"), + "{screen}" + ); + // The main device has the speaker's address, so it isn't repeated. + assert_eq!(screen.matches("10.0.0.2").count(), 1, "{screen}"); + } + + #[test] + fn scrolls_no_further_than_the_last_line() { + let state = state(); + let mut ui = UiState::default(); + ui.sync(&state); + press(&[KeyCode::Char('i'), KeyCode::End], &state, &mut ui); + + let at_end = screen(&state, &mut ui, 60, 12); + // The title bar, then the border: the last line is just above the bottom border. + assert!(at_end[10].contains("Volume"), "{at_end:#?}"); + + press(&[KeyCode::Down], &state, &mut ui); + assert_eq!(screen(&state, &mut ui, 60, 12), at_end); + } +}