use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::theme::Skin;
use ratada::fuzzy;
use crate::tui::scroll::Scroll;
use crate::tui::widgets::centered_box;
const CHROME_ROWS: u16 = 5;
const PREFERRED_WIDTH: u16 = 60;
const KEY_GAP: usize = 2;
pub type Section = (String, Vec<(String, String)>);
pub fn render(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: &HelpView,
scroll: &Scroll,
) {
let palette = &skin.palette;
let matching = filtered(view.sections, view.query);
let width = (area.width * 4 / 5).max(PREFERRED_WIDTH);
let height = section_rows(&matching) + CHROME_ROWS;
let rect = centered_box(width, height, area);
frame.render_widget(Clear, rect);
let block = ratada::chrome::modal_block(skin, "Keyboard shortcuts");
let inner = block.inner(rect);
let key_width = key_column_width(&matching);
let header_style =
ratada::style::fg(palette.accent_dim).add_modifier(Modifier::BOLD);
let key_style =
ratada::style::fg(palette.accent).add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = Vec::new();
for (title, hints) in &matching {
lines
.push(Line::from(Span::styled(title.to_uppercase(), header_style)));
for (key, description) in hints {
lines.push(Line::from(vec![
Span::styled(format!(" {key:key_width$}"), key_style),
Span::styled(
description.to_string(),
ratada::style::secondary(palette),
),
]));
}
}
if matching.is_empty() {
lines.push(Line::from(Span::styled(
format!(" no shortcut matches \"{}\"", view.query),
ratada::style::secondary(palette),
)));
}
let viewport = inner.height.saturating_sub(2);
let total = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let offset = scroll.fit(total, viewport);
frame.render_widget(block, rect);
let body = Rect {
height: viewport,
..inner
};
frame.render_widget(Paragraph::new(lines).scroll((offset, 0)), body);
ratada::scroll::render_scrollbar(
frame,
body,
skin,
ratada::nav::ScrollView {
total: total as usize,
offset: offset as usize,
viewport: viewport as usize,
},
);
let footer = Rect {
y: inner.y + inner.height - 1,
height: 1,
..inner
};
frame.render_widget(
Paragraph::new(footer_hint(skin, view.query, footer.width as usize)),
footer,
);
}
pub struct HelpView<'a> {
pub sections: &'a [Section],
pub query: &'a str,
}
fn filtered(sections: &[Section], query: &str) -> Vec<Section> {
if query.trim().is_empty() {
return sections.to_vec();
}
sections
.iter()
.filter_map(|(title, hints)| {
let kept: Vec<(String, String)> = hints
.iter()
.filter(|(key, description)| is_match(key, description, query))
.cloned()
.collect();
(!kept.is_empty()).then(|| (title.clone(), kept))
})
.collect()
}
fn is_match(key: &str, description: &str, query: &str) -> bool {
fuzzy::score(&format!("{key} {description}"), query).is_some()
}
fn section_rows(sections: &[Section]) -> u16 {
let rows: usize = sections.iter().map(|(_, hints)| hints.len() + 1).sum();
u16::try_from(rows.max(1)).unwrap_or(u16::MAX)
}
fn key_column_width(sections: &[Section]) -> usize {
sections
.iter()
.flat_map(|(_, hints)| hints.iter().map(|(key, _)| key.width()))
.max()
.unwrap_or(0)
+ KEY_GAP
}
fn footer_hint(skin: &Skin, query: &str, width: usize) -> Line<'static> {
let typed = format!("filter: {query}");
let hints: Vec<(&str, &str)> = if query.is_empty() {
vec![
("\u{2191}\u{2193}", "scroll"),
("type", "filter"),
("esc/?", "close"),
]
} else {
vec![
("\u{2191}\u{2193}", "scroll"),
(typed.as_str(), "esc clears"),
]
};
ratada::shortcut_hints::lines(&hints, skin.palette.accent_dim, width)
.into_iter()
.next()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::config::Config;
fn sample_sections() -> Vec<Section> {
vec![
(
"Navigation".to_string(),
vec![("space".to_string(), "toggle selection".to_string())],
),
(
"Open".to_string(),
vec![("L".to_string(), "open the git tool".to_string())],
),
(
"Global".to_string(),
vec![("q".to_string(), "quit".to_string())],
),
]
}
#[test]
fn renders_at_any_terminal_size() {
let skin = Config::default().skin();
let sections = sample_sections();
let scroll = Scroll::default();
for query in ["", "open"] {
for width in [1, 20, 40, 59, 60, 100, 126, 200, 400] {
for height in [1, 2, 3, 5, 40] {
let mut terminal =
Terminal::new(TestBackend::new(width, height))
.expect("the test backend never fails");
let view = HelpView {
sections: §ions,
query,
};
terminal
.draw(|frame| {
render(frame, frame.area(), &skin, &view, &scroll);
})
.unwrap_or_else(|error| {
panic!("{width}x{height} failed: {error}")
});
}
}
}
}
#[test]
fn an_empty_query_keeps_every_section() {
let sections = sample_sections();
assert_eq!(filtered(§ions, "").len(), 3);
assert_eq!(filtered(§ions, " ").len(), 3);
}
#[test]
fn a_query_keeps_only_matching_rows_and_drops_empty_sections() {
let sections = sample_sections();
let matching = filtered(§ions, "quit");
assert_eq!(matching.len(), 1, "only Global survives");
assert_eq!(matching[0].0, "Global");
assert_eq!(matching[0].1.len(), 1);
}
#[test]
fn a_query_matches_the_key_as_well_as_the_description() {
let sections = sample_sections();
let by_key = filtered(§ions, "L");
assert!(by_key.iter().any(|(title, _)| title == "Open"));
}
#[test]
fn a_query_matching_nothing_yields_no_sections() {
let sections = sample_sections();
assert!(filtered(§ions, "zzzzqqqq").is_empty());
}
}