mod render;
mod state;
use ratatui::style::{Modifier, Style};
pub use render::{footer_height, group_lines, height, lines, render};
pub use state::{
consume_toggle, default_toggle_key, global_bindings, set_toggle_key,
set_visible, toggle, toggle_key, visible,
};
use super::{keymap, style};
use crate::theme::Color;
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,
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::state::chord_label;
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);
}
fn while_hidden(body: impl FnOnce()) {
let before = visible();
set_visible(false);
body();
set_visible(before);
}
#[test]
fn hints_start_out_visible() {
assert!(visible());
}
#[test]
fn toggle_flips_the_visibility_back_and_forth() {
let before = visible();
toggle();
assert_eq!(visible(), !before);
toggle();
assert_eq!(visible(), before);
}
#[test]
fn hiding_the_hints_affects_only_the_grouped_footer() {
let flat = lines(ITEMS, Color::Default, 80);
while_hidden(|| {
assert_eq!(lines(ITEMS, Color::Default, 80), flat);
let groups = [HintGroup {
label: "A",
hints: ITEMS,
}];
assert!(group_lines(&groups, &HintStyle::default(), 80).is_empty());
});
}
#[test]
fn hidden_hints_reclaim_only_the_grouped_footer_rows() {
while_hidden(|| {
let groups = [HintGroup {
label: "A",
hints: ITEMS,
}];
assert_eq!(height(&groups, 80, 1), 0);
assert_eq!(footer_height(1), 1);
assert_eq!(footer_height(2), 2);
});
}
#[test]
fn footer_height_passes_the_rows_through_regardless_of_visibility() {
assert!(visible());
assert_eq!(footer_height(1), 1);
assert_eq!(footer_height(2), 2);
}
fn with_toggle_key(key: Option<KeyEvent>, body: impl FnOnce()) {
let before = toggle_key();
set_toggle_key(key);
body();
set_toggle_key(before);
}
#[test]
fn the_toggle_key_is_consumed_and_flips_the_visibility() {
let before = visible();
let key = default_toggle_key();
assert!(consume_toggle(key));
assert_eq!(visible(), !before);
assert!(consume_toggle(key));
assert_eq!(visible(), before);
}
#[test]
fn any_other_key_passes_through_untouched() {
let before = visible();
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
assert!(!consume_toggle(key));
assert_eq!(visible(), before);
}
#[test]
fn the_toggle_chord_matches_its_modifiers_exactly() {
let before = visible();
let shifted = KeyEvent::new(KeyCode::F(1), KeyModifiers::SHIFT);
assert!(!consume_toggle(shifted));
assert_eq!(visible(), before);
}
#[test]
fn an_unbound_toggle_lets_the_key_reach_the_host() {
with_toggle_key(None, || {
let before = visible();
assert!(!consume_toggle(default_toggle_key()));
assert_eq!(visible(), before);
});
}
#[test]
fn a_rebound_toggle_replaces_the_default_chord() {
let rebound = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL);
with_toggle_key(Some(rebound), || {
let before = visible();
assert!(!consume_toggle(default_toggle_key()));
assert_eq!(visible(), before);
assert!(consume_toggle(rebound));
assert_eq!(visible(), !before);
toggle();
});
}
#[test]
fn global_bindings_document_the_toggle_and_the_quit_chord() {
let bindings = global_bindings();
let keys: Vec<&str> =
bindings.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, ["f1", "ctrl+q"]);
}
#[test]
fn global_bindings_follow_the_toggle_binding() {
let rebound = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL);
with_toggle_key(Some(rebound), || {
let bindings = global_bindings();
assert_eq!(bindings[0].0, "ctrl+h");
});
with_toggle_key(None, || {
let bindings = global_bindings();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].0, "ctrl+q");
});
}
#[test]
fn chord_label_names_modifiers_and_keys() {
let label =
|code, modifiers| chord_label(KeyEvent::new(code, modifiers));
assert_eq!(label(KeyCode::F(1), KeyModifiers::NONE), "f1");
assert_eq!(label(KeyCode::Char('h'), KeyModifiers::CONTROL), "ctrl+h");
assert_eq!(label(KeyCode::Enter, KeyModifiers::SHIFT), "shift+enter");
assert_eq!(label(KeyCode::Esc, KeyModifiers::NONE), "esc");
assert_eq!(label(KeyCode::Char(' '), KeyModifiers::NONE), "space");
}
#[test]
fn chord_label_keeps_a_characters_case() {
let label =
|code, modifiers| chord_label(KeyEvent::new(code, modifiers));
assert_eq!(label(KeyCode::Char('G'), KeyModifiers::SHIFT), "G");
assert_eq!(label(KeyCode::Char('g'), KeyModifiers::NONE), "g");
}
#[test]
fn a_rendered_chord_parses_back_into_itself() {
for (code, modifiers) in [
(KeyCode::F(1), KeyModifiers::NONE),
(KeyCode::Char('h'), KeyModifiers::CONTROL),
(KeyCode::Char('G'), KeyModifiers::SHIFT),
(KeyCode::Enter, KeyModifiers::SHIFT),
(KeyCode::Left, KeyModifiers::ALT),
(KeyCode::Char(' '), KeyModifiers::NONE),
(KeyCode::Delete, KeyModifiers::NONE),
(KeyCode::PageUp, KeyModifiers::NONE),
] {
let key = KeyEvent::new(code, modifiers);
let label = chord_label(key);
let parsed = keymap::KeyChord::parse(&label)
.unwrap_or_else(|| panic!("'{label}' must parse back"));
assert!(parsed.matches(&key), "'{label}' must match {key:?}");
}
}
}