use ratatui::{
layout::{Constraint, Layout},
prelude::*,
text::Line,
widgets::{Block, Borders, Paragraph},
};
use crate::keys;
const SYNONYM_MAP: &[(&str, &[&str])] = &[
("commit", &["describe", "message", "new", "squash"]),
("rebase", &["move", "insert", "source", "destination"]),
(
"bookmark",
&["branch", "track", "untrack", "forget", "rename"],
),
("tag", &["release", "version", "label"]),
("undo", &["redo", "restore", "operation"]),
("diff", &["show", "compare", "blame", "export"]),
("search", &["filter", "revset"]),
("conflict", &["resolve", "merge"]),
("push", &["remote", "git"]),
("copy", &["clipboard", "export", "patch"]),
("navigate", &["next", "prev", "jump"]),
("edit", &["describe", "diffedit", "split", "fix", "editor"]),
("history", &["command", "execute", "log"]),
];
fn expand_synonyms(query_lower: &str) -> Vec<&'static str> {
if query_lower.is_empty() {
return Vec::new();
}
let mut expansions = Vec::new();
for &(trigger, terms) in SYNONYM_MAP {
if trigger.starts_with(query_lower) || query_lower.starts_with(trigger) {
expansions.extend_from_slice(terms);
}
}
expansions
}
#[allow(dead_code)]
pub struct HelpLine {
pub line: Line<'static>,
pub is_entry: bool,
pub matched: bool,
}
pub fn build_help_lines(search_query: Option<&str>) -> Vec<HelpLine> {
let query_lower = search_query.map(|q| q.to_lowercase());
let synonyms = query_lower
.as_deref()
.map(expand_synonyms)
.unwrap_or_default();
let mut lines = Vec::new();
lines.push(HelpLine {
line: Line::from("Key bindings:".bold()),
is_entry: false,
matched: false,
});
lines.push(HelpLine {
line: Line::from(""),
is_entry: false,
matched: false,
});
push_section(
&mut lines,
"Global",
keys::GLOBAL_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Navigation",
keys::NAV_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Log View",
keys::LOG_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Input Mode",
keys::INPUT_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Diff View",
keys::DIFF_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Status View",
keys::STATUS_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Bookmark View",
keys::BOOKMARK_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Tag View",
keys::TAG_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Command History View",
keys::COMMAND_HISTORY_KEYS,
query_lower.as_deref(),
&synonyms,
);
push_section(
&mut lines,
"Operation View",
keys::OPERATION_KEYS,
query_lower.as_deref(),
&synonyms,
);
lines
}
fn push_section(
lines: &mut Vec<HelpLine>,
title: &str,
entries: &[keys::KeyBindEntry],
query_lower: Option<&str>,
synonyms: &[&str],
) {
lines.push(HelpLine {
line: Line::from(format!("{title}:")).underlined(),
is_entry: false,
matched: false,
});
for entry in entries {
let matched = query_lower.is_some_and(|q| {
let key_lc = entry.key.to_lowercase();
let desc_lc = entry.description.to_lowercase();
key_lc.contains(q)
|| desc_lc.contains(q)
|| synonyms
.iter()
.any(|s| key_lc.contains(s) || desc_lc.contains(s))
});
let style = if matched {
Style::default().bg(Color::Yellow).fg(Color::Black)
} else {
Style::default()
};
let key_style = if matched {
Style::default().bg(Color::Yellow).fg(Color::Black).bold()
} else {
Style::default().fg(Color::Yellow)
};
lines.push(HelpLine {
line: Line::from(vec![
Span::styled(format!(" {:10}", entry.key), key_style),
Span::styled(entry.description.to_string(), style),
]),
is_entry: true,
matched,
});
}
lines.push(HelpLine {
line: Line::from(""),
is_entry: false,
matched: false,
});
}
pub fn matching_line_indices(query: &str) -> Vec<u16> {
build_help_lines(Some(query))
.iter()
.enumerate()
.filter(|(_, l)| l.matched)
.map(|(i, _)| i as u16)
.collect()
}
pub fn render_help_panel(
frame: &mut Frame,
area: Rect,
scroll: u16,
search_query: Option<&str>,
search_input: Option<&str>,
) {
let title = Line::from(" Tij - Help ").bold().white().centered();
let (help_area, input_area) = if search_input.is_some() {
let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(area);
(chunks[0], Some(chunks[1]))
} else {
(area, None)
};
let help_lines = build_help_lines(search_query);
let display_lines: Vec<Line<'static>> = help_lines.into_iter().map(|hl| hl.line).collect();
frame.render_widget(
Paragraph::new(display_lines)
.block(Block::default().borders(Borders::ALL).title(title))
.scroll((scroll, 0)),
help_area,
);
if let Some(buffer) = search_input {
let input_text = format!("Search: {buffer}");
let available_width = input_area.unwrap().width.saturating_sub(2) as usize;
let char_count = input_text.chars().count();
let display_text = if char_count > available_width && available_width > 0 {
let skip = char_count.saturating_sub(available_width.saturating_sub(1));
format!("…{}", input_text.chars().skip(skip).collect::<String>())
} else {
input_text.clone()
};
let input_bar = Paragraph::new(display_text).block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(" / Search ")),
);
let ia = input_area.unwrap();
frame.render_widget(input_bar, ia);
let cursor_pos = char_count.min(available_width);
frame.set_cursor_position((ia.x + cursor_pos as u16 + 1, ia.y + 1));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_help_lines_no_query_has_no_matches() {
let lines = build_help_lines(None);
assert!(lines.iter().all(|l| !l.matched));
assert!(!lines.is_empty());
}
#[test]
fn build_help_lines_quit_matches() {
let lines = build_help_lines(Some("quit"));
let matched: Vec<_> = lines.iter().filter(|l| l.matched).collect();
assert!(!matched.is_empty(), "Should match at least one Quit entry");
}
#[test]
fn build_help_lines_bookmark_matches_multiple_sections() {
let lines = build_help_lines(Some("bookmark"));
let matched: Vec<_> = lines.iter().filter(|l| l.matched).collect();
assert!(
matched.len() >= 2,
"bookmark should match in multiple sections"
);
}
#[test]
fn build_help_lines_no_match_returns_all_false() {
let lines = build_help_lines(Some("zzzzzznonexistent"));
assert!(lines.iter().all(|l| !l.matched));
}
#[test]
fn build_help_lines_case_insensitive() {
let upper = build_help_lines(Some("QUIT"));
let lower = build_help_lines(Some("quit"));
let upper_count = upper.iter().filter(|l| l.matched).count();
let lower_count = lower.iter().filter(|l| l.matched).count();
assert_eq!(
upper_count, lower_count,
"Search should be case-insensitive"
);
assert!(upper_count > 0);
}
#[test]
fn matching_line_indices_returns_correct_indices() {
let indices = matching_line_indices("quit");
assert!(!indices.is_empty());
let lines = build_help_lines(Some("quit"));
for &idx in &indices {
assert!(lines[idx as usize].matched);
}
}
#[test]
fn matching_line_indices_empty_for_nonexistent() {
let indices = matching_line_indices("zzzzz");
assert!(indices.is_empty());
}
#[test]
fn build_help_lines_entries_have_is_entry_true() {
let lines = build_help_lines(None);
let entries: Vec<_> = lines.iter().filter(|l| l.is_entry).collect();
assert!(entries.len() > 20, "Should have many key binding entries");
}
#[test]
fn expand_synonyms_commit_returns_related() {
let result = expand_synonyms("commit");
assert!(result.contains(&"describe"), "should contain describe");
assert!(result.contains(&"new"), "should contain new");
assert!(result.contains(&"squash"), "should contain squash");
}
#[test]
fn expand_synonyms_prefix_match() {
let result = expand_synonyms("reb");
assert!(result.contains(&"move"), "should contain move");
assert!(result.contains(&"source"), "should contain source");
}
#[test]
fn expand_synonyms_empty_returns_empty() {
let result = expand_synonyms("");
assert!(result.is_empty());
}
#[test]
fn expand_synonyms_no_match_returns_empty() {
let result = expand_synonyms("zzz");
assert!(result.is_empty());
}
#[test]
fn build_help_lines_commit_highlights_describe() {
let lines = build_help_lines(Some("commit"));
let matched_descs: Vec<_> = lines
.iter()
.filter(|l| l.matched && l.is_entry)
.filter_map(|l| l.line.spans.get(1).map(|s| s.content.to_lowercase()))
.collect();
assert!(
matched_descs.iter().any(|d| d.contains("describe")),
"commit search should highlight Describe entry via synonyms, got: {matched_descs:?}"
);
}
#[test]
fn build_help_lines_rebase_prefix_highlights_move() {
let lines = build_help_lines(Some("reb"));
let matched_descs: Vec<_> = lines
.iter()
.filter(|l| l.matched && l.is_entry)
.filter_map(|l| l.line.spans.get(1).map(|s| s.content.to_lowercase()))
.collect();
assert!(
matched_descs.iter().any(|d| d.contains("move")),
"reb search should highlight Move entries via rebase synonyms, got: {matched_descs:?}"
);
}
#[test]
fn build_help_lines_original_search_unaffected() {
let lines = build_help_lines(Some("quit"));
let matched: Vec<_> = lines.iter().filter(|l| l.matched).collect();
assert!(
!matched.is_empty(),
"quit should still match via original substring search"
);
}
#[test]
fn matching_line_indices_includes_synonyms() {
let commit_indices = matching_line_indices("commit");
let describe_indices = matching_line_indices("describe");
assert!(
!describe_indices.is_empty(),
"describe should match at least one entry"
);
let overlap = describe_indices
.iter()
.filter(|idx| commit_indices.contains(idx))
.count();
assert!(
overlap > 0,
"commit search should include at least one describe match via synonyms"
);
}
}