use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
use super::style;
use crate::theme::Color;
const SEPARATOR: &str = " \u{00b7} ";
const LABEL_GAP: usize = 2;
pub struct HintGroup<'a, S: AsRef<str>> {
pub label: &'a str,
pub hints: &'a [(S, S)],
}
pub struct HintStyle {
pub label: Style,
pub key: Style,
pub description: Style,
pub top_margin: u16,
pub background: Option<Color>,
}
impl Default for HintStyle {
fn default() -> Self {
Self {
label: style::dim(),
key: Style::default().add_modifier(Modifier::BOLD),
description: style::dim(),
top_margin: 1,
background: None,
}
}
}
pub fn lines<S: AsRef<str>>(
items: &[(S, S)],
key_color: Color,
width: usize,
) -> Vec<Line<'static>> {
let key_style = style::fg(key_color).add_modifier(Modifier::BOLD);
wrap(items, key_style, style::dim(), width)
.into_iter()
.map(Line::from)
.collect()
}
pub fn group_lines<S: AsRef<str>>(
groups: &[HintGroup<'_, S>],
opts: &HintStyle,
width: usize,
) -> Vec<Line<'static>> {
let label_col = label_column_width(groups);
let hint_width = width.saturating_sub(label_col).max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
for group in groups {
let rows = wrap(group.hints, opts.key, opts.description, hint_width);
for (row_index, mut hint_spans) in rows.into_iter().enumerate() {
let mut spans: Vec<Span<'static>> = Vec::new();
if label_col > 0 {
let is_label_row = row_index == 0 && !group.label.is_empty();
let cell = if is_label_row {
pad(&format!("{}:", group.label), label_col)
} else {
" ".repeat(label_col)
};
let cell_style = if is_label_row {
opts.label
} else {
Style::default()
};
spans.push(Span::styled(cell, cell_style));
}
spans.append(&mut hint_spans);
lines.push(Line::from(spans));
}
}
lines
}
pub fn height<S: AsRef<str>>(
groups: &[HintGroup<'_, S>],
width: usize,
top_margin: u16,
) -> u16 {
let count = group_lines(groups, &HintStyle::default(), width).len() as u16;
(count + top_margin).max(1)
}
pub fn render<S: AsRef<str>>(
frame: &mut Frame,
area: Rect,
groups: &[HintGroup<'_, S>],
opts: &HintStyle,
) {
let width = area.width as usize;
let lines = group_lines(groups, opts, width);
let margin = opts.top_margin.min(area.height);
let hint_area = Rect {
x: area.x,
y: area.y + margin,
width: area.width,
height: area.height.saturating_sub(margin),
};
let mut paragraph = Paragraph::new(lines);
if let Some(bg) = opts.background {
paragraph = paragraph.style(style::bg(bg));
}
frame.render_widget(paragraph, hint_area);
}
fn wrap<S: AsRef<str>>(
items: &[(S, S)],
key_style: Style,
desc_style: Style,
width: usize,
) -> Vec<Vec<Span<'static>>> {
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for (key, description) in items {
let (key, description) = (key.as_ref(), description.as_ref());
let token_width = format!("{key} {description}").width();
let separator_width = if spans.is_empty() {
0
} else {
SEPARATOR.width()
};
if !spans.is_empty() && used + separator_width + token_width > width {
rows.push(std::mem::take(&mut spans));
used = 0;
}
if !spans.is_empty() {
spans.push(Span::styled(SEPARATOR, desc_style));
used += SEPARATOR.width();
}
spans.push(Span::styled(format!("{key} "), key_style));
spans.push(Span::styled(description.to_string(), desc_style));
used += token_width;
}
if !spans.is_empty() {
rows.push(spans);
}
rows
}
fn label_column_width<S: AsRef<str>>(groups: &[HintGroup<'_, S>]) -> usize {
let widest = groups
.iter()
.filter(|group| !group.label.is_empty())
.map(|group| group.label.width() + 1) .max()
.unwrap_or(0);
if widest == 0 { 0 } else { widest + LABEL_GAP }
}
fn pad(text: &str, width: usize) -> String {
let current = text.width();
if current >= width {
text.to_string()
} else {
format!("{text}{}", " ".repeat(width - current))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ITEMS: &[(&str, &str)] = &[("a", "add"), ("q", "quit")];
#[test]
fn fits_on_one_line_when_wide() {
let result = lines(ITEMS, Color::Default, 80);
assert_eq!(result.len(), 1);
}
#[test]
fn wraps_to_multiple_lines_when_narrow() {
let result = lines(ITEMS, Color::Default, 6);
assert_eq!(result.len(), 2);
}
#[test]
fn group_labels_align_into_one_column() {
let nav = [("a", "up")];
let commands = [("b", "help")];
let groups = [
HintGroup {
label: "Nav",
hints: &nav,
},
HintGroup {
label: "Commands",
hints: &commands,
},
];
let lines = group_lines(&groups, &HintStyle::default(), 80);
assert_eq!(lines[0].spans[0].content.len(), 11);
assert_eq!(lines[1].spans[0].content.len(), 11);
assert!(lines[0].spans[0].content.starts_with("Nav:"));
assert!(lines[1].spans[0].content.starts_with("Commands:"));
}
#[test]
fn overflowing_group_wraps_indented_under_the_label() {
let hints = [("aaa", "bbb"), ("ccc", "ddd")];
let groups = [HintGroup {
label: "G",
hints: &hints,
}];
let lines = group_lines(&groups, &HintStyle::default(), 8);
assert_eq!(lines.len(), 2);
let indent = &lines[1].spans[0].content;
assert_eq!(indent.len(), 4);
assert!(indent.trim().is_empty());
}
#[test]
fn labelless_groups_render_flat() {
let hints = [("a", "add")];
let groups = [HintGroup {
label: "",
hints: &hints,
}];
let lines = group_lines(&groups, &HintStyle::default(), 80);
assert_eq!(lines[0].spans[0].content, "a ");
}
#[test]
fn height_counts_lines_plus_margin() {
let a = [("a", "x")];
let b = [("b", "y")];
let groups = [
HintGroup {
label: "A",
hints: &a,
},
HintGroup {
label: "B",
hints: &b,
},
];
assert_eq!(height(&groups, 80, 1), 3);
assert_eq!(height(&groups, 80, 0), 2);
}
}