228 lines
7.3 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|