1use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{ListItem, Paragraph};
11
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
14use crate::components::overlay::{Overlay, OverlayKind};
15use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
16use crate::components::rich_row::RichRow;
17use crate::components::search_list::{
18 Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
19};
20use crate::keys::leader::{LeaderAction, LeaderNode};
21use crate::settings::icons::Icons;
22use crate::settings::themes::Theme;
23
24#[derive(Clone)]
26pub struct CommandEntry {
27 pub label: String,
29 pub keys: String,
31 haystack: String,
33 pub action: LeaderAction,
34}
35
36impl SearchRow for CommandEntry {
37 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
38 RichRow::new("›", self.label.clone())
39 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
40 .meta(self.keys.clone())
41 .into_list_item(theme)
42 }
43
44 fn match_text(&self) -> Option<&str> {
45 Some(&self.haystack)
46 }
47
48 fn visual_height(&self) -> u16 {
49 1
50 }
51}
52
53pub fn command_entries(tree: &LeaderNode, gateway: &str) -> Vec<CommandEntry> {
55 fn walk(node: &LeaderNode, group: &str, keys: &str, out: &mut Vec<CommandEntry>) {
56 for (key, child) in node.children() {
57 let child_keys = format!("{keys} {key}");
58 match child {
59 LeaderNode::Leaf { action, .. } if *action == LeaderAction::Palette => {}
62 LeaderNode::Leaf { label, action } => {
63 let label = if group.is_empty() {
64 (*label).to_string()
65 } else {
66 format!("{group} · {label}")
67 };
68 out.push(CommandEntry {
69 haystack: format!("{label} {child_keys}"),
70 label,
71 keys: child_keys,
72 action: *action,
73 });
74 }
75 LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
76 }
77 }
78 }
79 let mut out = Vec::new();
80 walk(tree, "", gateway, &mut out);
81 out
82}
83
84pub struct CommandPaletteModal {
86 list: SearchList<CommandEntry>,
87}
88
89impl CommandPaletteModal {
90 pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
91 let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
94 .filter(Filter::Fuzzy)
95 .icons(icons)
96 .build_with_rows(command_entries(tree, gateway));
97 Self { list }
98 }
99
100 fn execute_selected(&self, tx: &AppTx) {
101 if let Some(entry) = self.list.selected_row() {
102 let action = entry.action;
103 tx.send(AppEvent::CloseOverlay).ok();
106 tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
107 }
108 }
109}
110
111impl Overlay for CommandPaletteModal {
112 fn kind(&self) -> OverlayKind {
113 OverlayKind::CommandPalette
114 }
115
116 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
117 match event {
118 InputEvent::Key(key) => match self.list.handle_key(key) {
119 KeyReaction::Submit => {
120 self.execute_selected(tx);
121 EventState::Consumed
122 }
123 KeyReaction::Cancel => {
124 tx.send(AppEvent::CloseOverlay).ok();
125 EventState::Consumed
126 }
127 _ => EventState::Consumed,
128 },
129 InputEvent::Mouse(mouse) => {
130 if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
131 self.execute_selected(tx);
132 }
133 EventState::Consumed
134 }
135 _ => EventState::NotConsumed,
136 }
137 }
138
139 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
140 let popup = crate::components::centered_rect(60, 60, area);
141 let inner = modal_chrome(
142 f,
143 popup,
144 theme,
145 ModalSpec {
146 title: Some(" Commands "),
147 bg: ModalBg::Hard,
148 ..Default::default()
149 },
150 );
151
152 let rows = Layout::default()
153 .direction(Direction::Vertical)
154 .constraints([
155 Constraint::Length(1),
156 Constraint::Min(0),
157 Constraint::Length(1),
158 ])
159 .split(inner);
160
161 let prefix = "› ";
163 f.render_widget(
164 Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
165 rows[0],
166 );
167 let input_rect = Rect {
168 x: rows[0].x + 2,
169 width: rows[0].width.saturating_sub(2),
170 ..rows[0]
171 };
172 self.list.render_query(f, input_rect, theme, true);
173
174 self.list.render(f, rows[1], theme, true);
175 self.list.set_list_rect(rows[1]);
176 self.list.set_panel_rect(popup);
177
178 f.render_widget(
179 Paragraph::new(Line::from(Span::styled(
180 "↑↓ move · ⏎ run · Esc close",
181 Style::default().fg(theme.gray.to_ratatui()),
182 ))),
183 rows[2],
184 );
185
186 self.list.render_autocomplete(f, popup, theme);
187 }
188
189 fn hint_shortcuts(&self) -> Vec<(String, String)> {
190 vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn entries_cover_every_leader_leaf() {
200 let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
201 assert!(entries.len() > 20);
203 assert!(
204 entries
205 .iter()
206 .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
207 );
208 assert!(
209 entries
210 .iter()
211 .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
212 );
213 }
214
215 #[tokio::test(flavor = "multi_thread")]
216 async fn enter_closes_then_executes() {
217 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
218 let mut palette = CommandPaletteModal::new(
219 &crate::keys::leader::leader_tree(),
220 "Ctrl+G",
221 Icons::new(false),
222 tx.clone(),
223 );
224 for _ in 0..50 {
226 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
227 palette.list.poll();
228 }
229 assert!(palette.list.selected_row().is_some());
230
231 palette.handle_input(
232 &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
233 ratatui::crossterm::event::KeyCode::Enter,
234 ratatui::crossterm::event::KeyModifiers::NONE,
235 )),
236 &tx,
237 );
238
239 let mut order = Vec::new();
240 while let Ok(ev) = rx.try_recv() {
241 match ev {
242 AppEvent::CloseOverlay => order.push("close"),
243 AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
244 _ => {}
245 }
246 }
247 assert_eq!(order, vec!["close", "execute"]);
248 }
249}