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