use std::{cell::Cell, io};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use super::{
fuzzy,
layout::centered_rect,
list,
modal::ModalSignal,
nav,
overlay::{self, PopupFlow, popup},
style,
terminal::Tui,
};
use crate::theme::Skin;
pub struct HelpSection<'a, B: AsRef<str>> {
pub title: &'a str,
pub bindings: &'a [(B, B)],
}
struct Help {
query: String,
cursor: usize,
offset: Cell<usize>,
}
enum Row<'a> {
Header(&'a str),
Item { key: &'a str, description: &'a str },
}
struct RowLayout<'a> {
rows: Vec<Row<'a>>,
selectable: Vec<usize>,
section_starts: Vec<usize>,
}
pub fn show<B: AsRef<str>>(
tui: &mut Tui,
skin: &Skin,
sections: &[HelpSection<'_, B>],
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<()>> {
let mut state = Help {
query: String::new(),
cursor: 0,
offset: Cell::new(0),
};
popup(
tui,
&mut state,
|area, _| {
centered_rect(
(area.width * 2 / 3).clamp(40, area.width),
(area.height * 2 / 3).clamp(8, area.height),
area,
)
},
|frame, _| render_bg(frame),
|frame, rect, state: &Help| {
let inner = overlay::framed(frame, rect, skin, "Help");
render_body(frame, inner, skin, sections, state);
},
|state, key| match key.code {
KeyCode::Esc | KeyCode::Char('?') => PopupFlow::Done(()),
KeyCode::Up => {
let count =
layout_rows(sections, &state.query).selectable.len();
state.cursor = nav::cycle(state.cursor, count, -1);
PopupFlow::Continue
}
KeyCode::Down => {
let count =
layout_rows(sections, &state.query).selectable.len();
state.cursor = nav::cycle(state.cursor, count, 1);
PopupFlow::Continue
}
KeyCode::Tab => {
jump_section(state, sections, 1);
PopupFlow::Continue
}
KeyCode::BackTab => {
jump_section(state, sections, -1);
PopupFlow::Continue
}
KeyCode::Backspace => {
state.query.pop();
state.cursor = 0;
PopupFlow::Continue
}
KeyCode::Char(ch) => {
state.query.push(ch);
state.cursor = 0;
PopupFlow::Continue
}
_ => PopupFlow::Continue,
},
)
}
fn jump_section<B: AsRef<str>>(
state: &mut Help,
sections: &[HelpSection<'_, B>],
direction: isize,
) {
let starts = layout_rows(sections, &state.query).section_starts;
if starts.is_empty() {
return;
}
let current = starts
.iter()
.rposition(|&start| start <= state.cursor)
.unwrap_or(0);
let next = nav::cycle(current, starts.len(), direction);
state.cursor = starts[next];
}
fn layout_rows<'a, B: AsRef<str>>(
sections: &'a [HelpSection<'a, B>],
query: &str,
) -> RowLayout<'a> {
let query = query.trim();
let mut rows: Vec<Row<'a>> = Vec::new();
let mut selectable: Vec<usize> = Vec::new();
let mut section_starts: Vec<usize> = Vec::new();
for section in sections {
let matches: Vec<&(B, B)> = section
.bindings
.iter()
.filter(|(key, description)| {
is_match(key.as_ref(), description.as_ref(), query)
})
.collect();
if matches.is_empty() {
continue;
}
section_starts.push(selectable.len());
rows.push(Row::Header(section.title));
for (key, description) in matches {
selectable.push(rows.len());
rows.push(Row::Item {
key: key.as_ref(),
description: description.as_ref(),
});
}
}
RowLayout {
rows,
selectable,
section_starts,
}
}
fn is_match(key: &str, description: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
fuzzy::score(&format!("{key} {description}"), query).is_some()
}
fn render_body<B: AsRef<str>>(
frame: &mut Frame,
inner: Rect,
skin: &Skin,
sections: &[HelpSection<'_, B>],
state: &Help,
) {
let palette = &skin.palette;
let layout = layout_rows(sections, &state.query);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(inner);
let search = Line::from(vec![
Span::styled("search ", style::dim()),
Span::raw(state.query.clone()),
Span::styled(
" ",
Style::default().bg(style::to_ratatui(palette.cursor)),
),
]);
frame.render_widget(Paragraph::new(search), rows[0]);
let header_style =
style::fg(palette.accent_dim).add_modifier(Modifier::BOLD);
let entries: Vec<Line<'static>> = layout
.rows
.iter()
.map(|row| match row {
Row::Header(title) => {
Line::from(Span::styled(title.to_uppercase(), header_style))
}
Row::Item { key, description } => {
let mut spans = vec![Span::styled(
format!(" {key:<12}"),
style::fg(palette.accent).add_modifier(Modifier::BOLD),
)];
spans.extend(fuzzy::highlight(
description,
&state.query,
style::dim(),
palette,
));
Line::from(spans)
}
})
.collect();
let selected = layout
.selectable
.get(state.cursor.min(layout.selectable.len().saturating_sub(1)))
.copied()
.unwrap_or(0);
list::render(frame, rows[1], skin, entries, selected, &state.offset);
let hint = footer_hint(skin, rows[2].width as usize);
frame.render_widget(Paragraph::new(hint), rows[2]);
}
fn footer_hint(skin: &Skin, width: usize) -> Line<'static> {
super::footer::lines(
&[
("\u{2191}\u{2193}", "move"),
("tab", "section"),
("esc", "close"),
],
skin.palette.accent_dim,
width,
)
.into_iter()
.next()
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn sections() -> Vec<HelpSection<'static, &'static str>> {
vec![
HelpSection {
title: "Navigation",
bindings: &[("k", "up"), ("j", "down")],
},
HelpSection {
title: "Tasks",
bindings: &[("a", "add task"), ("d", "delete")],
},
]
}
#[test]
fn empty_query_keeps_every_section_and_item() {
let secs = sections();
let layout = layout_rows(&secs, "");
assert_eq!(layout.rows.len(), 6);
assert_eq!(layout.selectable.len(), 4);
assert_eq!(layout.section_starts, vec![0, 2]);
}
#[test]
fn query_filters_items_and_drops_empty_sections() {
let secs = sections();
let layout = layout_rows(&secs, "add");
assert_eq!(layout.selectable.len(), 1);
assert_eq!(layout.section_starts, vec![0]);
assert!(matches!(layout.rows[0], Row::Header("Tasks")));
}
#[test]
fn section_jump_lands_on_the_first_item_of_the_target() {
let secs = sections();
let mut state = Help {
query: String::new(),
cursor: 0,
offset: Cell::new(0),
};
jump_section(&mut state, &secs, 1);
assert_eq!(state.cursor, 2);
jump_section(&mut state, &secs, 1);
assert_eq!(state.cursor, 0);
jump_section(&mut state, &secs, -1);
assert_eq!(state.cursor, 2);
}
}