use std::io;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
use super::{
chrome,
filter_list::FilterList,
fuzzy, input,
layout::centered_fraction,
list,
modal::ModalSignal,
overlay::{self, PopupFlow, popup_with_paste},
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)],
}
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 = FilterList::new();
popup_with_paste(
tui,
&mut state,
|area, _| centered_fraction(area, 2, 3, 40, 8),
|frame, _| render_bg(frame),
|frame, rect, state: &FilterList| {
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| handle_key(state, key, sections),
|state, text| {
state
.query
.extend(text.chars().filter(|ch| !ch.is_control()));
state.cursor = 0;
PopupFlow::Continue
},
)
}
fn handle_key<B: AsRef<str>>(
state: &mut FilterList,
key: KeyEvent,
sections: &[HelpSection<'_, B>],
) -> PopupFlow<()> {
if input::is_command(key) {
return PopupFlow::Continue;
}
if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
return PopupFlow::Done(());
}
let layout = layout_rows(sections, &state.query);
state.handle_key(key, layout.selectable.len(), &layout.section_starts);
PopupFlow::Continue
}
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: &FilterList,
) {
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);
let viewport = list::render(
frame,
rows[1],
skin,
list::ListView {
rows: entries,
selected,
offset: &state.offset,
},
);
state.viewport.set(viewport);
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 crossterm::event::KeyModifiers;
use super::*;
fn state() -> FilterList {
FilterList::new()
}
fn jump_section<B: AsRef<str>>(
state: &mut FilterList,
sections: &[HelpSection<'_, B>],
direction: isize,
) {
let code = if direction > 0 {
KeyCode::Tab
} else {
KeyCode::BackTab
};
handle_key(state, KeyEvent::new(code, KeyModifiers::NONE), sections);
}
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 = state();
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);
}
#[test]
fn ctrl_chords_do_not_navigate_or_type() {
let secs = sections();
for code in [
KeyCode::Down,
KeyCode::Up,
KeyCode::End,
KeyCode::PageDown,
KeyCode::Tab,
KeyCode::Char('u'),
KeyCode::Char('?'),
] {
let mut state = state();
let key = KeyEvent::new(code, KeyModifiers::CONTROL);
assert!(matches!(
handle_key(&mut state, key, &secs),
PopupFlow::Continue
));
assert_eq!(state.cursor, 0, "Ctrl+{code:?} moved the cursor");
assert!(state.query.is_empty(), "Ctrl+{code:?} typed a character");
}
}
#[test]
fn bare_keys_still_navigate_and_type() {
let secs = sections();
let mut state = state();
let press = |code| KeyEvent::new(code, KeyModifiers::NONE);
handle_key(&mut state, press(KeyCode::Down), &secs);
assert_eq!(state.cursor, 1);
handle_key(&mut state, press(KeyCode::End), &secs);
assert_eq!(state.cursor, 3);
handle_key(&mut state, press(KeyCode::Char('u')), &secs);
assert_eq!(state.query, "u");
assert_eq!(state.cursor, 0, "typing restarts the selection");
assert!(matches!(
handle_key(&mut state, press(KeyCode::Esc), &secs),
PopupFlow::Done(())
));
}
#[test]
fn altgr_characters_still_reach_the_filter() {
let secs = sections();
let mut state = state();
for ch in ['@', '\\', '['] {
handle_key(
&mut state,
KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
&secs,
);
}
assert_eq!(state.query, "@\\[");
}
}