1use ratatui::Frame;
7use ratatui::crossterm::event::KeyCode;
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::Style;
10use ratatui::widgets::{ListItem, Paragraph};
11
12use crate::components::clickable::is_press_outside;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
15use crate::components::hint_row::HintRow;
16use crate::components::overlay::{Overlay, OverlayKind};
17use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
18use crate::components::rich_row::RichRow;
19use crate::components::search_list::{
20 Filter, KeyReaction, SearchList, SearchMouse, SearchRow, StaticRowSource,
21};
22use crate::keys::leader::{LeaderAction, LeaderNode};
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26#[derive(Clone)]
28pub struct CommandEntry {
29 pub label: String,
31 pub keys: String,
33 haystack: String,
35 pub action: LeaderAction,
36}
37
38impl SearchRow for CommandEntry {
39 fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
40 RichRow::new("›", self.label.clone())
41 .glyph_style(Style::default().fg(theme.gray.to_ratatui()))
42 .meta(self.keys.clone())
43 .into_list_item(theme)
44 }
45
46 fn match_text(&self) -> Option<&str> {
47 Some(&self.haystack)
48 }
49
50 fn visual_height(&self) -> u16 {
51 1
52 }
53}
54
55pub fn command_entries(tree: &LeaderNode, gateway: &str) -> Vec<CommandEntry> {
57 fn walk(node: &LeaderNode, group: &str, keys: &str, out: &mut Vec<CommandEntry>) {
58 for (key, child) in node.children() {
59 let child_keys = format!("{keys} {key}");
60 match child {
61 LeaderNode::Leaf { action, .. } if *action == LeaderAction::Palette => {}
64 LeaderNode::Leaf {
68 action: LeaderAction::PinnedJump(_),
69 ..
70 } => {}
71 LeaderNode::Leaf { label, action } => {
72 let label = if group.is_empty() {
73 (*label).to_string()
74 } else {
75 format!("{group} · {label}")
76 };
77 out.push(CommandEntry {
78 haystack: format!("{label} {child_keys}"),
79 label,
80 keys: child_keys,
81 action: *action,
82 });
83 }
84 LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
85 }
86 }
87 }
88 let mut out = Vec::new();
89 walk(tree, "", gateway, &mut out);
90 out
91}
92
93pub struct CommandPaletteModal {
95 list: SearchList<CommandEntry>,
96 popup_rect: Rect,
98 hints: HintRow,
99}
100
101impl CommandPaletteModal {
102 pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
103 let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
106 .filter(Filter::Fuzzy)
107 .icons(icons)
108 .build_with_rows(command_entries(tree, gateway));
109 Self {
110 list,
111 popup_rect: Rect::default(),
112 hints: HintRow::new(&[
113 (KeyCode::Null, "↑↓", "Move"),
114 (KeyCode::Enter, "⏎", "Run"),
115 (KeyCode::Esc, "Esc", "Close"),
116 ])
117 .with_indent(0),
118 }
119 }
120
121 fn execute_selected(&self, tx: &AppTx) {
122 if let Some(entry) = self.list.selected_row() {
123 let action = entry.action;
124 tx.send(AppEvent::CloseOverlay).ok();
127 tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
128 }
129 }
130}
131
132impl Overlay for CommandPaletteModal {
133 fn kind(&self) -> OverlayKind {
134 OverlayKind::CommandPalette
135 }
136
137 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
138 match event {
139 InputEvent::Key(key) => match self.list.handle_key(key) {
140 KeyReaction::Submit => {
141 self.execute_selected(tx);
142 EventState::Consumed
143 }
144 KeyReaction::Cancel => {
145 tx.send(AppEvent::CloseOverlay).ok();
146 EventState::Consumed
147 }
148 KeyReaction::Yank(target) => {
152 crate::components::yank_row(target, tx);
153 EventState::Consumed
154 }
155 _ => EventState::Consumed,
156 },
157 InputEvent::Mouse(mouse) => {
158 if is_press_outside(mouse, self.popup_rect) {
159 tx.send(AppEvent::CloseOverlay).ok();
160 return EventState::Consumed;
161 }
162 if let Some(key) = self.hints.hit(mouse) {
163 return self.handle_input(&InputEvent::Key(key), tx);
164 }
165 if let SearchMouse::Activated(_)
166 | SearchMouse::DoubleClicked { repeat: false, .. } =
167 self.list.handle_mouse(mouse)
168 {
169 self.execute_selected(tx);
170 }
171 EventState::Consumed
172 }
173 _ => EventState::NotConsumed,
174 }
175 }
176
177 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
178 let popup = crate::components::centered_rect(60, 60, area);
179 self.popup_rect = popup;
180 let inner = modal_chrome(
181 f,
182 popup,
183 theme,
184 ModalSpec {
185 title: Some(" Commands "),
186 bg: ModalBg::Hard,
187 ..Default::default()
188 },
189 );
190
191 let rows = Layout::default()
192 .direction(Direction::Vertical)
193 .constraints([
194 Constraint::Length(1),
195 Constraint::Min(0),
196 Constraint::Length(1),
197 ])
198 .split(inner);
199
200 let prefix = "› ";
202 f.render_widget(
203 Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
204 rows[0],
205 );
206 let input_rect = Rect {
207 x: rows[0].x + 2,
208 width: rows[0].width.saturating_sub(2),
209 ..rows[0]
210 };
211 self.list.render_query(f, input_rect, theme, true);
212
213 self.list.render(f, rows[1], theme, true);
214 self.list.set_list_rect(rows[1]);
215 self.list.set_panel_rect(popup);
216
217 self.hints.render(
218 f,
219 rows[2],
220 Style::default().fg(theme.gray.to_ratatui()),
221 theme,
222 );
223
224 self.list.render_autocomplete(f, popup, theme);
225 }
226
227 fn hint_shortcuts(&self) -> Vec<(String, String)> {
228 vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn palette() -> (CommandPaletteModal, AppTx) {
237 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
238 let p = CommandPaletteModal::new(
239 &crate::keys::leader::leader_tree(),
240 "Ctrl+B",
241 Icons::new(false),
242 tx.clone(),
243 );
244 (p, tx)
245 }
246
247 fn drawn(p: &mut CommandPaletteModal) -> ratatui::buffer::Buffer {
248 use ratatui::{Terminal, backend::TestBackend};
249 let theme = Theme::gruvbox_dark();
250 let mut t = Terminal::new(TestBackend::new(100, 40)).unwrap();
251 t.draw(|f| p.render(f, f.area(), &theme)).unwrap();
252 t.backend().buffer().clone()
253 }
254
255 fn click(p: &mut CommandPaletteModal, col: u16, row: u16) -> Vec<AppEvent> {
256 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
257 p.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
258 crate::test_support::drain(&mut rx).into_iter().collect()
259 }
260
261 #[test]
262 fn outside_press_and_close_chip_close_the_palette() {
263 let (mut p, _tx) = palette();
264 drawn(&mut p);
265 assert!(
266 click(&mut p, 0, 0)
267 .iter()
268 .any(|e| matches!(e, AppEvent::CloseOverlay))
269 );
270 let buf = drawn(&mut p);
271 let (col, row) = crate::test_support::find_text(&buf, "[Esc]").expect("[Esc] chip drawn");
272 assert!(
273 click(&mut p, col, row)
274 .iter()
275 .any(|e| matches!(e, AppEvent::CloseOverlay))
276 );
277 }
278
279 #[test]
280 fn entries_cover_every_leader_leaf() {
281 let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
282 assert!(entries.len() > 20);
284 assert!(
285 entries
286 .iter()
287 .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
288 );
289 assert!(
290 entries
291 .iter()
292 .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
293 );
294 assert!(
295 !entries
296 .iter()
297 .any(|e| matches!(e.action, LeaderAction::PinnedJump(_))),
298 "the nine jump leaves stay out of the palette"
299 );
300 assert!(entries.iter().any(|e| e.action == LeaderAction::FindPinned));
301 assert!(
302 entries
303 .iter()
304 .any(|e| e.action == LeaderAction::NoteTogglePin)
305 );
306 }
307
308 #[tokio::test(flavor = "multi_thread")]
309 async fn enter_closes_then_executes() {
310 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
311 let mut palette = CommandPaletteModal::new(
312 &crate::keys::leader::leader_tree(),
313 "Ctrl+G",
314 Icons::new(false),
315 tx.clone(),
316 );
317 for _ in 0..50 {
319 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
320 palette.list.poll();
321 }
322 assert!(palette.list.selected_row().is_some());
323
324 palette.handle_input(
325 &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
326 ratatui::crossterm::event::KeyCode::Enter,
327 ratatui::crossterm::event::KeyModifiers::NONE,
328 )),
329 &tx,
330 );
331
332 let mut order = Vec::new();
333 while let Ok(ev) = rx.try_recv() {
334 match ev {
335 AppEvent::CloseOverlay => order.push("close"),
336 AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
337 _ => {}
338 }
339 }
340 assert_eq!(order, vec!["close", "execute"]);
341 }
342}