1use ratatui::{
26 layout::{Alignment, Rect},
27 text::{Line, Span, Text},
28 widgets::{Block, Borders, Clear, Paragraph},
29 Frame,
30};
31
32use crate::Theme;
33
34
35#[derive(Debug, Clone, Copy)]
45pub struct LeaderNode {
46 pub key: char,
47 pub label: &'static str,
48 pub children: &'static [LeaderNode],
49 pub is_divider: bool,
50}
51
52impl LeaderNode {
53 pub const fn new(key: char, label: &'static str, children: &'static [LeaderNode]) -> Self {
54 Self { key, label, children, is_divider: false }
55 }
56
57 pub const fn divider() -> Self {
59 Self { key: '\0', label: "", children: &[], is_divider: true }
60 }
61}
62
63pub struct LeaderState {
66 pub active: bool,
67 sequence: Vec<char>,
68 pub root: &'static [LeaderNode],
69 last_key_at: Option<std::time::Instant>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum LeaderResult {
77 Matched(Vec<char>),
79 Pending,
81 Cancelled,
83}
84
85impl LeaderState {
86 pub fn new(root: &'static [LeaderNode]) -> Self {
87 Self { active: false, sequence: Vec::new(), root, last_key_at: None }
88 }
89
90 pub fn arm(&mut self) {
92 self.active = true;
93 self.sequence.clear();
94 self.last_key_at = Some(std::time::Instant::now());
95 }
96
97 pub fn cancel(&mut self) {
99 self.active = false;
100 self.sequence.clear();
101 self.last_key_at = None;
102 }
103
104 pub fn push(&mut self, c: char) -> LeaderResult {
106 self.last_key_at = Some(std::time::Instant::now());
107 self.sequence.push(c);
108
109 match self.find_node(&self.sequence.clone()) {
110 NodeMatch::Leaf => {
111 let path = self.sequence.clone();
112 self.cancel();
113 LeaderResult::Matched(path)
114 }
115 NodeMatch::Prefix => LeaderResult::Pending,
116 NodeMatch::None => {
117 self.cancel();
118 LeaderResult::Cancelled
119 }
120 }
121 }
122
123 pub fn tick(&mut self) -> bool {
125 false
126 }
127
128 pub fn current_entries(&self) -> &'static [LeaderNode] {
130 let mut level = self.root;
131 for &c in &self.sequence {
132 if let Some(node) = level.iter().filter(|n| !n.is_divider).find(|n| n.key == c) {
133 level = node.children;
134 } else {
135 return &[];
136 }
137 }
138 level
139 }
140
141 fn find_node(&self, path: &[char]) -> NodeMatch {
144 let mut level = self.root;
145 for (i, &c) in path.iter().enumerate() {
146 match level.iter().filter(|n| !n.is_divider).find(|n| n.key == c) {
147 None => return NodeMatch::None,
148 Some(node) => {
149 if i == path.len() - 1 {
150 if node.children.is_empty() {
151 return NodeMatch::Leaf;
152 } else {
153 return NodeMatch::Prefix;
154 }
155 }
156 level = node.children;
157 }
158 }
159 }
160 NodeMatch::Prefix
161 }
162}
163
164enum NodeMatch { Leaf, Prefix, None }
165
166fn entry_line(entry: &LeaderNode, theme: &Theme) -> Line<'static> {
174 let label = entry.label;
175 let key = entry.key;
176 let has_children = !entry.children.is_empty();
177
178 let key_lower = key.to_lowercase().next().unwrap_or(key);
179 let pos = label
180 .char_indices()
181 .find(|(_, c)| c.to_lowercase().next().unwrap_or(*c) == key_lower);
182
183 let mut spans = vec![Span::raw(" ")]; if let Some((byte_idx, _)) = pos {
186 let before = &label[..byte_idx];
188 let char_len = key.len_utf8();
189 let after = &label[byte_idx + char_len..];
190
191 if !before.is_empty() {
192 spans.push(Span::styled(before.to_string(), theme.body));
193 }
194 spans.push(Span::styled(format!("[{}]", key), theme.shortcut_key));
195 if !after.is_empty() {
196 spans.push(Span::styled(after.to_string(), theme.body));
197 }
198 } else {
199 spans.push(Span::styled(format!("[{}]", key), theme.shortcut_key));
201 spans.push(Span::styled(format!(" {}", label), theme.body));
202 }
203
204 if has_children {
205 spans.push(Span::styled(" →".to_string(), theme.hint));
206 }
207
208 Line::from(spans)
209}
210
211fn max_label_width_recursive(nodes: &[LeaderNode]) -> usize {
214 nodes.iter().filter(|n| !n.is_divider).map(|n| {
215 let own = n.label.chars().count() + 3 + if !n.children.is_empty() { 2 } else { 0 };
216 own.max(max_label_width_recursive(n.children))
217 }).max().unwrap_or(0)
218}
219
220const POPUP_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
223
224pub fn render_leader_bar(f: &mut Frame, state: &LeaderState, area: Rect, theme: &Theme) {
225 if !state.active {
226 return;
227 }
228 if state.last_key_at.map(|t| t.elapsed() < POPUP_DELAY).unwrap_or(false) {
231 return;
232 }
233
234 let entries = state.current_entries();
235 let breadcrumb: String = state.sequence.iter().collect();
236
237 let max_entry_chars = max_label_width_recursive(state.root).max(10) as u16;
240 let bar_width = (max_entry_chars + 4).min(area.width.saturating_sub(4));
241
242 let inner_w = bar_width.saturating_sub(2) as usize;
244
245 let mut padded: Vec<Line> = vec![Line::from("")]; for (i, entry) in entries.iter().enumerate() {
250 if entry.is_divider {
251 if i > 0 {
253 padded.push(Line::from(""));
254 }
255 let rule = "─".repeat(inner_w);
256 padded.push(Line::from(Span::styled(rule, theme.separator)));
257 } else {
259 let prev_is_divider = i > 0 && entries[i - 1].is_divider;
260 if i > 0 {
261 padded.push(Line::from("")); }
263 padded.push(entry_line(entry, theme));
264 let _ = prev_is_divider; }
266 }
267 padded.push(Line::from("")); let bar_height = (padded.len() as u16) + 2; let x = area.x + area.width.saturating_sub(bar_width) / 2;
274 let y = area.y + area.height.saturating_sub(bar_height) / 2;
275 let bar_area = Rect { x, y, width: bar_width, height: bar_height };
276
277 let title = if breadcrumb.is_empty() {
278 " ⌁ ".to_string()
279 } else {
280 format!(" ⌁ {} › ", breadcrumb)
281 };
282
283 f.render_widget(Clear, bar_area);
284 let paragraph = Paragraph::new(Text::from(padded))
285 .block(
286 Block::default()
287 .borders(Borders::ALL)
288 .border_style(theme.border_popup)
289 .title(title)
290 .title_style(theme.section_header)
291 .title_alignment(Alignment::Center),
292 );
293 f.render_widget(paragraph, bar_area);
294}