use crate::app::App;
use crate::config::keys::parse_seq;
use crate::input::mode::Mode;
use crate::input::pending::{Key, KeyCode};
use crate::render::splash;
pub type Hint = (String, String);
pub fn hints(app: &App) -> Vec<Hint> {
if !app.config.hints.enabled
|| app.zen_on()
|| app.help.is_some()
|| app.diff.is_some()
|| splash::active(app)
{
return Vec::new();
}
let leader = leader_label(&app.config.input.leader);
let pending = app.pending();
let visual = matches!(app.mode, Mode::Visual | Mode::VisualLine);
if let Some(prefix) = pending.prefix() {
return prefix_hints(prefix, visual);
}
if !pending.partial.is_empty() {
let table = if visual { &app.config.keys.visual } else { &app.config.keys.normal };
return sequence_hints(table, &pending.partial, &app.config.input.leader);
}
if pending.operator.is_some() {
return list(&[
("w b e $", "a motion"),
("iw ip i(", "an object"),
("again", "the whole line"),
("Esc", "cancel"),
]);
}
if app.finder.is_some() {
return list(&[
("type", "narrow"),
("Ctrl-n Ctrl-p", "move"),
("Enter", "open"),
("Esc", "close"),
]);
}
match &app.mode {
Mode::Prompt(_) => {
return list(&[("Enter", "confirm"), ("Ctrl-u", "clear"), ("Esc", "cancel")]);
}
Mode::Command(_) => {
return list(&[("Enter", "run"), ("Esc", "cancel"), (":help", "every command")]);
}
Mode::Search { .. } => {
return list(&[("Enter", "find"), ("Esc", "cancel"), ("n N", "next · previous after")]);
}
_ => {}
}
if app.tree.as_ref().is_some_and(|t| t.focused) {
return vec![
hint("l", "open"),
hint("q", "close"),
hint("a", "new"),
hint("r", "rename"),
hint("m", "move"),
hint("d", "delete"),
hint(&format!("{leader} hl"), "help"),
hint("- =", "root"),
hint("H", "dotfiles"),
];
}
if app.view {
return vec![
hint("j k", "scroll"),
hint("/", "search"),
hint("i", "edit"),
hint(&format!("{leader} v"), "edit"),
hint("q", "quit"),
];
}
match app.mode {
Mode::Insert => list(&[("Esc", "Normal"), ("Ctrl-s", "save"), ("Tab", "indent a list item")]),
Mode::Visual | Mode::VisualLine => list(&[
("d c y", "delete · change · yank"),
("> <", "indent"),
("o", "other end"),
("Esc", "cancel"),
]),
_ => vec![
hint("i", "write"),
hint("v", "select"),
hint("/", "search"),
hint(":", "command"),
hint(&format!("{leader} hl"), "help"),
],
}
}
fn hint(keys: &str, does: &str) -> Hint {
(keys.to_string(), does.to_string())
}
fn list(items: &[(&str, &str)]) -> Vec<Hint> {
items.iter().map(|(k, d)| hint(k, d)).collect()
}
fn leader_label(leader: &str) -> String {
match leader {
" " => "Space".into(),
"" => "\\".into(),
other => other.into(),
}
}
fn prefix_hints(prefix: char, visual: bool) -> Vec<Hint> {
match prefix {
'g' => {
let mut out = list(&[
("b i h k", "bold · italic · highlight · code"),
("l", "link"),
("t", "task"),
("a", "table"),
("1-6 0", "heading"),
]);
if !visual {
out.extend(list(&[("g", "top"), ("f x", "follow · open"), ("p", "paragraph"), ("u U", "case")]));
}
out
}
'w' => list(&[
("h j k l", "move"),
("v s", "split"),
("q", "close"),
("o", "only"),
("w", "cycle"),
("> < + - =", "size"),
]),
_ => list(&[("Esc", "cancel")]),
}
}
fn sequence_hints(table: &std::collections::HashMap<String, String>, typed: &[Key], leader: &str) -> Vec<Hint> {
let mut finished: Vec<(String, String)> = Vec::new();
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for (keys, action) in table {
if matches!(action.as_str(), "" | "none" | "nop") {
continue;
}
let Ok(seq) = parse_seq(keys, leader) else { continue };
if seq.len() <= typed.len() || seq[..typed.len()] != *typed {
continue;
}
let next = key_label(&seq[typed.len()]);
if seq.len() == typed.len() + 1 {
finished.push((next, label(action)));
} else if let Some((_, actions)) = groups.iter_mut().find(|(k, _)| *k == next) {
actions.push(action.clone());
} else {
groups.push((next, vec![action.clone()]));
}
}
groups.retain(|(k, _)| !finished.iter().any(|(f, _)| f == k));
let mut out: Vec<Hint> = finished;
out.extend(groups.into_iter().map(|(k, actions)| (format!("{k}…"), group_label(&actions))));
out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then(a.0.cmp(&b.0)));
out
}
fn key_label(key: &Key) -> String {
let base = match key.code {
KeyCode::Char(' ') => "Space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".into(),
KeyCode::Esc => "Esc".into(),
KeyCode::Tab => "Tab".into(),
KeyCode::Backspace => "BS".into(),
KeyCode::Delete => "Del".into(),
KeyCode::Up => "Up".into(),
KeyCode::Down => "Down".into(),
KeyCode::Left => "Left".into(),
KeyCode::Right => "Right".into(),
KeyCode::Home => "Home".into(),
KeyCode::End => "End".into(),
KeyCode::PageUp => "PgUp".into(),
KeyCode::PageDown => "PgDn".into(),
};
let mut out = String::new();
if key.ctrl {
out.push_str("Ctrl-");
}
if key.alt {
out.push_str("Alt-");
}
if key.shift {
out.push_str("Shift-");
}
out + &base
}
fn label(action: &str) -> String {
let short = match action {
"save" | "write" => "save",
"quit" => "quit",
"help" | "shortcuts" => "help",
"cycle_focus" | "focus" => "focus",
"toggle_typewriter" | "typewriter" => "typewriter",
"toggle_zen" | "zen" => "zen",
"toggle_conceal" | "conceal" => "conceal",
"toggle_numbers" | "numbers" | "number" => "numbers",
"cycle_align" | "align" => "align",
"toggle_view" | "view" | "read_only" => "read-only",
"file_tree" | "file_explorer" | "tree" => "tree",
"file_tree_home" | "file_explorer_home" => "tree in ~",
"file_tree_cwd" | "file_explorer_cwd" => "tree in launch folder",
"find_file" | "fuzzy_find" | "files" => "find",
"find_file_home" | "fuzzy_find_home" => "find in ~",
"find_file_cwd" | "fuzzy_find_cwd" => "find in launch folder",
"find_buffer" | "buffers" | "switch_buffer" => "buffers",
"split_vertical" | "vsplit" => "split beside",
"split_horizontal" | "split" => "split below",
"only_pane" | "only" => "only this pane",
"close_pane" => "close pane",
"toggle_bold" | "bold" => "bold",
"toggle_italic" | "italic" => "italic",
other => return other.replace('_', " "),
};
short.to_string()
}
fn group_label(actions: &[String]) -> String {
let all = |words: &[&str]| actions.iter().all(|a| words.iter().any(|w| a.contains(w)));
if all(&["file", "find", "tree", "buffer", "fuzzy"]) {
"files".into()
} else if all(&["split", "pane", "only"]) {
"splits".into()
} else if all(&["help", "shortcuts"]) {
"help".into()
} else if actions.len() == 1 {
label(&actions[0])
} else {
format!("{} more", actions.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::text::cursor::Cursor;
use ratatui::crossterm::event::{KeyCode as Ct, KeyEvent, KeyModifiers};
fn app() -> App {
let cfg = crate::config::parse("").unwrap();
let mut app = App::new(cfg, None, None).unwrap();
app.buffer.insert_str(Cursor::new(0, 0), "some text\n");
app.buffer.cursor = Cursor::new(0, 0);
app
}
fn press(app: &mut App, c: char) {
app.on_key(KeyEvent::new(Ct::Char(c), KeyModifiers::NONE));
}
fn keys(h: &[Hint]) -> Vec<&str> {
h.iter().map(|(k, _)| k.as_str()).collect()
}
#[test]
fn normal_mode_names_the_way_in() {
let app = app();
let h = hints(&app);
assert!(keys(&h).contains(&"i"), "{h:?}");
assert!(keys(&h).contains(&"Space hl"), "{h:?}");
}
#[test]
fn after_the_leader_it_lists_what_follows() {
let mut app = app();
press(&mut app, ' ');
let h = hints(&app);
assert!(h.contains(&("w".into(), "save".into())), "{h:?}");
assert!(h.contains(&("f…".into(), "files".into())), "{h:?}");
assert!(h.contains(&("s…".into(), "splits".into())), "{h:?}");
press(&mut app, 'f');
let h = hints(&app);
assert!(h.contains(&("e".into(), "tree".into())), "{h:?}");
assert!(h.contains(&("F".into(), "find in ~".into())), "{h:?}");
press(&mut app, 'e');
assert!(app.tree.is_some(), "the binding still runs");
}
#[test]
fn a_builtin_prefix_lists_its_second_keys() {
let mut app = app();
press(&mut app, 'g');
assert!(keys(&hints(&app)).contains(&"b i h k"));
app.on_key(KeyEvent::new(Ct::Char('w'), KeyModifiers::CONTROL));
app.on_key(KeyEvent::new(Ct::Esc, KeyModifiers::NONE));
app.on_key(KeyEvent::new(Ct::Char('w'), KeyModifiers::CONTROL));
assert!(keys(&hints(&app)).contains(&"v s"), "{:?}", hints(&app));
}
#[test]
fn modes_and_panels_have_their_own() {
let mut app = app();
press(&mut app, 'i');
assert!(keys(&hints(&app)).contains(&"Esc"));
app.on_key(KeyEvent::new(Ct::Esc, KeyModifiers::NONE));
press(&mut app, 'd');
assert!(keys(&hints(&app)).contains(&"again"), "operator pending");
app.on_key(KeyEvent::new(Ct::Esc, KeyModifiers::NONE));
app.set_view(true);
assert!(keys(&hints(&app)).contains(&"Space v"));
}
#[test]
fn off_and_zen_show_nothing() {
let mut app = app();
app.config.hints.enabled = false;
assert!(hints(&app).is_empty());
app.config.hints.enabled = true;
app.set_zen(true);
assert!(hints(&app).is_empty());
}
#[test]
fn rebound_leader_keys_show_as_rebound() {
let mut cfg = Config::default();
cfg.keys.normal.insert("<leader>x".into(), "toggle_zen".into());
let mut app = App::new(cfg, None, None).unwrap();
app.buffer.insert_str(Cursor::new(0, 0), "t\n");
press(&mut app, ' ');
assert!(hints(&app).contains(&("x".into(), "zen".into())));
}
}