use ratatui::{
layout::{Alignment, Rect},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::Theme;
#[derive(Debug, Clone, Copy)]
pub struct LeaderNode {
pub key: char,
pub label: &'static str,
pub children: &'static [LeaderNode],
pub is_divider: bool,
}
impl LeaderNode {
pub const fn new(key: char, label: &'static str, children: &'static [LeaderNode]) -> Self {
Self { key, label, children, is_divider: false }
}
pub const fn divider() -> Self {
Self { key: '\0', label: "", children: &[], is_divider: true }
}
}
pub const POPUP_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
pub struct LeaderState {
pub active: bool,
sequence: Vec<char>,
pub root: &'static [LeaderNode],
last_key_at: Option<std::time::Instant>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LeaderResult {
Matched(Vec<char>),
Pending,
Cancelled,
}
impl LeaderState {
pub fn new(root: &'static [LeaderNode]) -> Self {
Self { active: false, sequence: Vec::new(), root, last_key_at: None }
}
pub fn arm(&mut self) {
self.active = true;
self.sequence.clear();
self.last_key_at = Some(std::time::Instant::now());
}
pub fn cancel(&mut self) {
self.active = false;
self.sequence.clear();
self.last_key_at = None;
}
pub fn push(&mut self, c: char) -> LeaderResult {
let still_hiding = self.last_key_at
.map(|t| t.elapsed() < POPUP_DELAY)
.unwrap_or(false);
if still_hiding {
self.last_key_at = Some(std::time::Instant::now());
}
self.sequence.push(c);
match self.find_node(&self.sequence.clone()) {
NodeMatch::Leaf => {
let path = self.sequence.clone();
self.cancel();
LeaderResult::Matched(path)
}
NodeMatch::Prefix => LeaderResult::Pending,
NodeMatch::None => {
self.cancel();
LeaderResult::Cancelled
}
}
}
pub fn tick(&mut self) -> bool {
false
}
pub fn current_entries(&self) -> &'static [LeaderNode] {
let mut level = self.root;
for &c in &self.sequence {
if let Some(node) = level.iter().filter(|n| !n.is_divider).find(|n| n.key == c) {
level = node.children;
} else {
return &[];
}
}
level
}
fn find_node(&self, path: &[char]) -> NodeMatch {
let mut level = self.root;
for (i, &c) in path.iter().enumerate() {
match level.iter().filter(|n| !n.is_divider).find(|n| n.key == c) {
None => return NodeMatch::None,
Some(node) => {
if i == path.len() - 1 {
if node.children.is_empty() {
return NodeMatch::Leaf;
} else {
return NodeMatch::Prefix;
}
}
level = node.children;
}
}
}
NodeMatch::Prefix
}
}
enum NodeMatch { Leaf, Prefix, None }
fn entry_line(entry: &LeaderNode, theme: &Theme) -> Line<'static> {
let label = entry.label;
let key = entry.key;
let has_children = !entry.children.is_empty();
let key_lower = key.to_lowercase().next().unwrap_or(key);
let pos = label
.char_indices()
.find(|(_, c)| c.to_lowercase().next().unwrap_or(*c) == key_lower);
let mut spans = vec![Span::raw(" ")];
if let Some((byte_idx, _)) = pos {
let before = &label[..byte_idx];
let char_len = key.len_utf8();
let after = &label[byte_idx + char_len..];
if !before.is_empty() {
spans.push(Span::styled(before.to_string(), theme.body));
}
spans.push(Span::styled(format!("[{}]", key), theme.shortcut_key));
if !after.is_empty() {
spans.push(Span::styled(after.to_string(), theme.body));
}
} else {
spans.push(Span::styled(format!("[{}]", key), theme.shortcut_key));
spans.push(Span::styled(format!(" {}", label), theme.body));
}
if has_children {
spans.push(Span::styled(" →".to_string(), theme.hint));
}
Line::from(spans)
}
fn max_label_width_recursive(nodes: &[LeaderNode]) -> usize {
nodes.iter().filter(|n| !n.is_divider).map(|n| {
let own = n.label.chars().count() + 3 + if !n.children.is_empty() { 2 } else { 0 };
own.max(max_label_width_recursive(n.children))
}).max().unwrap_or(0)
}
pub fn render_leader_bar(f: &mut Frame, state: &LeaderState, area: Rect, theme: &Theme) {
if !state.active {
return;
}
if state.last_key_at.map(|t| t.elapsed() < POPUP_DELAY).unwrap_or(false) {
return;
}
let entries = state.current_entries();
let breadcrumb: String = state.sequence.iter().collect();
let max_entry_chars = max_label_width_recursive(state.root).max(10) as u16;
let bar_width = (max_entry_chars + 4).min(area.width.saturating_sub(4));
let inner_w = bar_width.saturating_sub(2) as usize;
let mut padded: Vec<Line> = vec![Line::from("")]; for (i, entry) in entries.iter().enumerate() {
if entry.is_divider {
if i > 0 {
padded.push(Line::from(""));
}
let rule = "─".repeat(inner_w);
padded.push(Line::from(Span::styled(rule, theme.separator)));
} else {
let prev_is_divider = i > 0 && entries[i - 1].is_divider;
if i > 0 {
padded.push(Line::from("")); }
padded.push(entry_line(entry, theme));
let _ = prev_is_divider; }
}
padded.push(Line::from(""));
let bar_height = (padded.len() as u16) + 2;
let x = area.x + area.width.saturating_sub(bar_width) / 2;
let y = area.y + area.height.saturating_sub(bar_height) / 2;
let bar_area = Rect { x, y, width: bar_width, height: bar_height };
let title = if breadcrumb.is_empty() {
" ⌁ ".to_string()
} else {
format!(" ⌁ {} › ", breadcrumb)
};
f.render_widget(Clear, bar_area);
let paragraph = Paragraph::new(Text::from(padded))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(theme.border_popup)
.title(title)
.title_style(theme.section_header)
.title_alignment(Alignment::Center),
);
f.render_widget(paragraph, bar_area);
}