use std::cell::Cell;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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;
const TOGGLE_LABEL: &str = "toggle hints";
const HARD_QUIT: (&str, &str) = ("ctrl+q", "force quit");
thread_local! {
static VISIBLE: Cell<bool> = const { Cell::new(true) };
static TOGGLE: Cell<Option<KeyEvent>> = Cell::new(Some(default_toggle_key()));
}
pub fn visible() -> bool {
VISIBLE.with(Cell::get)
}
pub fn set_visible(show: bool) {
VISIBLE.with(|flag| flag.set(show));
}
pub fn toggle() {
VISIBLE.with(|flag| flag.set(!flag.get()));
}
pub fn default_toggle_key() -> KeyEvent {
KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE)
}
pub fn toggle_key() -> Option<KeyEvent> {
TOGGLE.with(Cell::get)
}
pub fn set_toggle_key(key: Option<KeyEvent>) {
TOGGLE.with(|chord| chord.set(key));
}
pub fn global_bindings() -> Vec<(String, String)> {
let mut bindings = Vec::new();
if let Some(chord) = toggle_key() {
bindings.push((chord_label(chord), TOGGLE_LABEL.to_string()));
}
bindings.push((HARD_QUIT.0.to_string(), HARD_QUIT.1.to_string()));
bindings
}
pub fn footer_height(rows: u16) -> u16 {
if visible() { rows } else { 0 }
}
pub fn consume_toggle(key: KeyEvent) -> bool {
let Some(bound) = toggle_key() else {
return false;
};
if key.code != bound.code || key.modifiers != bound.modifiers {
return false;
}
toggle();
true
}
fn chord_label(key: KeyEvent) -> String {
let mut label = String::new();
for (modifier, name) in [
(KeyModifiers::CONTROL, "ctrl+"),
(KeyModifiers::ALT, "alt+"),
(KeyModifiers::SHIFT, "shift+"),
] {
if key.modifiers.contains(modifier) {
label.push_str(name);
}
}
label.push_str(&key_label(key.code));
label
}
fn key_label(code: KeyCode) -> String {
match code {
KeyCode::Char(' ') => "space".to_string(),
KeyCode::Char(ch) => ch.to_lowercase().to_string(),
KeyCode::F(number) => format!("f{number}"),
KeyCode::Enter => "enter".to_string(),
KeyCode::Esc => "esc".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "backtab".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pageup".to_string(),
KeyCode::PageDown => "pagedown".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
other => format!("{other:?}").to_lowercase(),
}
}
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>> {
if !visible() {
return Vec::new();
}
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>> {
if !visible() {
return Vec::new();
}
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 {
if !visible() {
return 0;
}
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,
) {
if !visible() {
return;
}
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 crossterm::event::KeyModifiers;
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 hidden_hints_yield_no_lines() {
while_hidden(|| {
assert!(lines(ITEMS, Color::Default, 80).is_empty());
let groups = [HintGroup {
label: "A",
hints: ITEMS,
}];
assert!(group_lines(&groups, &HintStyle::default(), 80).is_empty());
});
}
#[test]
fn hidden_hints_reclaim_their_rows_and_the_top_margin() {
while_hidden(|| {
let groups = [HintGroup {
label: "A",
hints: ITEMS,
}];
assert_eq!(height(&groups, 80, 1), 0);
assert_eq!(footer_height(1), 0);
assert_eq!(footer_height(2), 0);
});
}
#[test]
fn footer_height_passes_the_rows_through_while_visible() {
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");
}
}