crabidy/cbd-tui/src/app/help.rs

228 lines
7.3 KiB
Rust

//! 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<Line<'static>>,
left: Vec<Line<'static>>,
right: Vec<Line<'static>>,
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::<Vec<_>>()
.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<Line<'static>> {
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());
}
}
}