Skip to main content

tui_kit/
leader.rs

1//! Vim-style leader key bar — generic, reusable across apps.
2//!
3//! ## Usage
4//!
5//! 1. Define your menu tree as a `&'static [LeaderNode]`.
6//! 2. Create `LeaderState::new(root)`.
7//! 3. Call `arm()` when the leader key is pressed.
8//! 4. On each key: call `push(c)` → `LeaderResult`.
9//! 5. Call `tick()` each event-loop iteration (handles timeout).
10//! 6. Call `render_leader_bar(f, &state, screen_area, &theme)` in your draw fn.
11//!
12//! ## Label rendering
13//!
14//! Each [`LeaderNode`] takes a full `label` string (e.g. `"navigate"`, `"find"`).
15//! The key character is highlighted inline with `[k]` brackets if it appears in
16//! the label, otherwise it is displayed as a prefix:
17//!
18//! ```text
19//! [f]ind         ← 'f' found at position 0
20//! e[x]ecute      ← 'x' found at position 1
21//! [g] navigate   ← 'g' not in "navigate"
22//! ```
23
24
25use 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// ── Data model ────────────────────────────────────────────────────────────────
36
37/// One entry in a leader menu level.
38///
39/// `label` is the full descriptive word shown to the user (e.g. `"navigate"`,
40/// `"find"`, `"create"`). The `key` character is highlighted inline if it
41/// appears in `label`, otherwise it is shown as a `[k]` prefix.
42///
43/// Use [`LeaderNode::divider()`] to insert a visual separator between sections.
44#[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    /// A purely visual horizontal divider — not selectable, ignored by key matching.
58    pub const fn divider() -> Self {
59        Self { key: '\0', label: "", children: &[], is_divider: true }
60    }
61}
62
63// ── State ─────────────────────────────────────────────────────────────────────
64
65pub struct LeaderState {
66    pub active: bool,
67    sequence: Vec<char>,
68    pub root: &'static [LeaderNode],
69    /// Time of the last key event (arm or char press). The popup is only shown
70    /// once 200 ms have passed with no new keypress, so fast typists never see a flash.
71    last_key_at: Option<std::time::Instant>,
72}
73
74/// Outcome of pressing a key while the leader is active.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum LeaderResult {
77    /// Sequence matched a complete leaf — contains the key path pressed.
78    Matched(Vec<char>),
79    /// Valid prefix — more keys expected.
80    Pending,
81    /// No match — leader cancelled.
82    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    /// Arm the leader (start listening).
91    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    /// Cancel and reset.
98    pub fn cancel(&mut self) {
99        self.active = false;
100        self.sequence.clear();
101        self.last_key_at = None;
102    }
103
104    /// Handle a character key press while leader is active.
105    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    /// Call once per event-loop tick. Always returns `false` (no timeout).
124    pub fn tick(&mut self) -> bool {
125        false
126    }
127
128    /// The entries visible at the current depth (for rendering).
129    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    // ── private ───────────────────────────────────────────────────────────────
142
143    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
166// ── Rendering ─────────────────────────────────────────────────────────────────
167
168/// Build the display [`Line`] for a single leader entry.
169///
170/// If `key` appears in `label` (case-insensitive), it is highlighted inline:
171/// `[f]ind`, `e[x]ecute`. Otherwise the key is shown as a prefix: `[g] navigate`.
172/// Entries with children get a `→` suffix.
173fn 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(" ")]; // left padding
184
185    if let Some((byte_idx, _)) = pos {
186        // Inline highlight: prefix[key]suffix
187        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        // Prefix: [key] label
200        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
211/// Recursively compute the maximum visible character width of any entry in the
212/// tree — used so the popup width stays stable across all submenu levels.
213fn 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
220/// Render the leader bar as a centered column popup.
221/// Does nothing when `state.active` is false.
222const 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    // Only show the popup once 200 ms have passed since the last keypress.
229    // This way any sequence typed faster than that never causes a flash.
230    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    // Width: stable across all submenu levels (accounts for deepest label)
238    // + 1 left padding + 1 right padding + 2 borders
239    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    // Inner width for divider lines (bar_width minus 2 borders)
243    let inner_w = bar_width.saturating_sub(2) as usize;
244
245    // Build content lines with spacing:
246    //   - blank line between same-section entries
247    //   - blank line before AND after each divider
248    let mut padded: Vec<Line> = vec![Line::from("")]; // top padding
249    for (i, entry) in entries.iter().enumerate() {
250        if entry.is_divider {
251            // Blank before divider (unless it's the very first item)
252            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            // Blank after divider is handled by next entry's leading blank
258        } else {
259            let prev_is_divider = i > 0 && entries[i - 1].is_divider;
260            if i > 0 {
261                padded.push(Line::from("")); // blank before every non-first entry
262            }
263            padded.push(entry_line(entry, theme));
264            let _ = prev_is_divider; // blank already pushed above
265        }
266    }
267    padded.push(Line::from("")); // bottom padding
268
269    // Height derived from actual content
270    let bar_height = (padded.len() as u16) + 2; // +2 borders
271
272    // Position: centered horizontally and vertically within `area`
273    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}