//! The help modal: a centered overlay listing usage notes and all key //! bindings, rendered entirely from [`super::bindings::BINDINGS`]. use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, Paragraph}, Frame, }; use super::bindings::{key_label, Scope, BINDINGS}; use super::{COLOR_PRIMARY, COLOR_SECONDARY}; /// The modal's content: a usage blurb, two binding columns (Global left, /// Library + Queue right, so the whole table fits a typical frame), and a /// close-keys footer derived from the `Scope::Help` bindings. struct HelpContent { usage: Vec>, left: Vec>, right: Vec>, footer: Line<'static>, } impl HelpContent { fn build() -> Self { let usage = vec![ Line::from("Browse the library (left pane) and manage the play queue (right pane)."), Line::from("Press Tab to switch focus; keys apply globally or to the focused pane."), Line::from(""), ]; let left = group(Scope::Global, "Global"); let mut right = group(Scope::Library, "Library"); right.push(Line::from("")); right.extend(group(Scope::Queue, "Queue")); // All Help-scope chords close the modal; derive their labels instead // of hardcoding key names. let close_keys = BINDINGS .iter() .filter(|b| b.scope == Scope::Help) .map(|b| key_label(b.mods, b.code)) .collect::>() .join(", "); let footer = Line::from(Span::styled( format!("Close help: {close_keys}"), Style::default().fg(COLOR_SECONDARY), )); Self { usage, left, right, footer, } } fn left_width(&self) -> u16 { max_width(&self.left) } /// Content size excluding the popup borders. fn size(&self) -> (u16, u16) { let columns = self.left_width() + COLUMN_GAP + max_width(&self.right); let width = columns .max(max_width(&self.usage)) .max(self.footer.width() as u16); let height = self.usage.len() as u16 + (self.left.len().max(self.right.len()) as u16) + 2 // blank line + footer ; (width, height) } } const COLUMN_GAP: u16 = 2; fn max_width(lines: &[Line<'_>]) -> u16 { lines.iter().map(|l| l.width() as u16).max().unwrap_or(0) } /// One scope's bindings as a styled header plus `key description` rows, /// with key labels right-aligned to the group's widest label. fn group(scope: Scope, title: &'static str) -> Vec> { let entries: Vec<_> = BINDINGS.iter().filter(|b| b.scope == scope).collect(); let key_width = entries .iter() .map(|b| key_label(b.mods, b.code).chars().count()) .max() .unwrap_or(0); let mut lines = vec![Line::from(Span::styled( title, Style::default() .fg(COLOR_SECONDARY) .add_modifier(Modifier::BOLD), ))]; lines.extend(entries.iter().map(|b| { Line::from(vec![ Span::styled( format!("{:>key_width$}", key_label(b.mods, b.code)), Style::default().fg(COLOR_PRIMARY), ), Span::from(format!(" {}", b.description)), ]) })); lines } /// Render the help modal over the current frame. /// /// Draws a `Clear`-backed, centered popup on top of whatever is already in /// the frame. If the frame is smaller than the content, the popup is clamped /// to the frame and overflowing lines are truncated (no scrolling — see /// architecture/help-modal.md, open questions). pub fn render(f: &mut Frame) { let content = HelpContent::build(); let area = popup_area(f.area()); f.render_widget(Clear, area); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(COLOR_PRIMARY)) .title("Help"); let inner = block.inner(area); f.render_widget(block, area); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(content.usage.len() as u16), Constraint::Min(0), Constraint::Length(1), ]) .split(inner); let columns = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(content.left_width() + COLUMN_GAP), Constraint::Min(0), ]) .split(rows[1]); f.render_widget(Paragraph::new(content.usage.clone()), rows[0]); f.render_widget(Paragraph::new(content.left.clone()), columns[0]); f.render_widget(Paragraph::new(content.right.clone()), columns[1]); f.render_widget(Paragraph::new(vec![content.footer.clone()]), rows[2]); } /// The centered popup rectangle: sized to the help content but clamped to /// `frame`, never exceeding it. fn popup_area(frame: Rect) -> Rect { let (content_w, content_h) = HelpContent::build().size(); let width = content_w.saturating_add(2).min(frame.width); let height = content_h.saturating_add(2).min(frame.height); Rect::new( frame.x + (frame.width - width) / 2, frame.y + (frame.height - height) / 2, width, height, ) } #[cfg(test)] mod tests { use super::*; use ratatui::{backend::TestBackend, Terminal}; fn render_to_buffer(width: u16, height: u16) -> ratatui::buffer::Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("test terminal"); terminal.draw(render).expect("draw help"); terminal.backend().buffer().clone() } fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { let mut text = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { text.push_str(buf[(x, y)].symbol()); } text.push('\n'); } text } #[test] fn help_lists_bindings_from_the_table() { let text = buffer_text(&render_to_buffer(100, 40)); // Spot-check one entry per scope, by description from BINDINGS. assert!(text.contains("Quit")); assert!(text.contains("Enter selected folder")); assert!(text.contains("Remove selected track")); assert!(text.contains("Close help")); } #[test] fn help_explains_basic_usage() { let text = buffer_text(&render_to_buffer(100, 40)); // The usage blurb must mention the panes and how to switch focus. assert!(text.contains("Tab")); assert!(text.to_lowercase().contains("library")); assert!(text.to_lowercase().contains("queue")); } #[test] fn help_survives_tiny_terminals() { // Truncation, not panic, on frames smaller than the content. for (w, h) in [(10, 5), (20, 10), (1, 1)] { let _ = render_to_buffer(w, h); } } #[test] fn popup_never_exceeds_the_frame() { for (w, h) in [(100, 40), (30, 12), (5, 3)] { let frame = Rect::new(0, 0, w, h); let popup = popup_area(frame); assert!(popup.right() <= frame.right()); assert!(popup.bottom() <= frame.bottom()); } } }