use std::{cell::Cell, io};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
use super::{
chrome, fuzzy, input,
layout::centered_fraction,
list,
modal::ModalSignal,
nav,
overlay::{self, PopupFlow, popup},
shortcut_hints, style,
terminal::Tui,
};
use crate::theme::Skin;
const SEARCH_LABEL: &str = "search ";
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_fraction(area, 2, 3, 40, 8),
|frame, _| render_bg(frame),
|frame, rect, state: &Help| {
let inner = overlay::framed(frame, rect, skin, "Help");
render_body(frame, inner, skin, sections, state);
let count = layout_rows(sections, &state.query).selectable.len();
let cursor = state.cursor.min(count.saturating_sub(1));
let badge = chrome::position_badge(cursor, count);
chrome::render_badge(frame, rect, skin, &badge);
},
|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(shortcut_hints::footer_height(1)),
])
.split(inner);
let mut search =
vec![Span::styled(SEARCH_LABEL, style::secondary(palette))];
search.extend(input::query_spans(
&state.query,
palette,
(rows[0].width as usize).saturating_sub(SEARCH_LABEL.len()),
));
frame.render_widget(Paragraph::new(Line::from(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::secondary(palette),
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,
list::ListView {
rows: entries,
selected,
offset: &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> {
shortcut_hints::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);
}
}