use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
use super::Theme;
pub type Section<'a> = (&'a str, &'a [(&'a str, &'a str)]);
pub struct Help<'a> {
pub sections: &'a [Section<'a>],
pub theme: &'a Theme,
}
impl Help<'_> {
fn section_height(section: &Section<'_>) -> usize {
section.1.len() + 2
}
fn split(&self) -> (&[Section<'_>], &[Section<'_>]) {
let total: usize = self.sections.iter().map(Help::section_height).sum();
let mut used = 0;
let mut at = self.sections.len();
for (i, section) in self.sections.iter().enumerate() {
if used * 2 >= total {
at = i;
break;
}
used += Help::section_height(section);
}
self.sections.split_at(at)
}
fn column_width(sections: &[Section<'_>], key_width: usize) -> usize {
sections
.iter()
.flat_map(|(heading, entries)| {
std::iter::once(heading.chars().count()).chain(
entries
.iter()
.map(|(_, action)| 4 + key_width + action.chars().count()),
)
})
.max()
.unwrap_or(0)
}
fn render_column(&self, sections: &[Section<'_>], key_width: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for (heading, entries) in sections {
lines.push(Line::from(Span::styled(
heading.to_string(),
Style::new()
.fg(self.theme.header)
.add_modifier(Modifier::BOLD),
)));
for (keys, action) in *entries {
lines.push(Line::from(vec![
Span::styled(
format!(" {keys:key_width$} "),
Style::new().fg(self.theme.match_bg),
),
Span::raw(action.to_string()),
]));
}
lines.push(Line::raw(""));
}
lines
}
}
impl Widget for Help<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (left, right) = self.split();
let key_width = self
.sections
.iter()
.flat_map(|(_, entries)| entries.iter())
.map(|(keys, _)| keys.chars().count())
.max()
.unwrap_or(8);
let body_height = left
.iter()
.map(Help::section_height)
.sum::<usize>()
.max(right.iter().map(Help::section_height).sum::<usize>());
const GAP: usize = 2;
let left_width = Help::column_width(left, key_width);
let right_width = Help::column_width(right, key_width);
let width = ((left_width + GAP + right_width + 2) as u16)
.min(area.width)
.max(20);
let height = ((body_height + 2) as u16).min(area.height).max(3);
let popup = Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
};
Clear.render(popup, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(self.theme.border))
.title(" Keys — any key to close ");
let inner = block.inner(popup);
block.render(popup, buf);
let [left_area, _, right_area] = Layout::horizontal([
Constraint::Length(left_width as u16),
Constraint::Length(GAP as u16),
Constraint::Min(0),
])
.areas(inner);
Paragraph::new(self.render_column(left, key_width)).render(left_area, buf);
Paragraph::new(self.render_column(right, key_width)).render(right_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
const A: &[(&str, &str)] = &[("j", "down"), ("k", "up")];
const B: &[(&str, &str)] = &[("q", "quit")];
#[test]
fn split_balances_columns() {
let theme = Theme::catppuccin_mocha();
let sections: &[Section] = &[("Move", A), ("Other", B)];
let help = Help {
sections,
theme: &theme,
};
let (left, right) = help.split();
assert_eq!(left.len(), 1);
assert_eq!(right.len(), 1);
}
#[test]
fn split_keeps_single_section_in_one_column() {
let theme = Theme::catppuccin_mocha();
let sections: &[Section] = &[("Move", A)];
let help = Help {
sections,
theme: &theme,
};
let (left, right) = help.split();
assert_eq!(left.len(), 1);
assert!(right.is_empty());
}
#[test]
fn column_width_fits_the_widest_row() {
assert_eq!(Help::column_width(&[("Move", A)], 3), 4 + 3 + 4);
}
}