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 KeyReaction::Yank(target) => {
131 crate::components::yank_row(target, tx);
132 EventState::Consumed
133 }
134 _ => EventState::Consumed,
135 },
136 InputEvent::Mouse(mouse) => {
137 if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
138 self.execute_selected(tx);
139 }
140 EventState::Consumed
141 }
142 _ => EventState::NotConsumed,
143 }
144 }
145
146 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
147 let popup = crate::components::centered_rect(60, 60, area);
148 let inner = modal_chrome(
149 f,
150 popup,
151 theme,
152 ModalSpec {
153 title: Some(" Commands "),
154 bg: ModalBg::Hard,
155 ..Default::default()
156 },
157 );
158
159 let rows = Layout::default()
160 .direction(Direction::Vertical)
161 .constraints([
162 Constraint::Length(1),
163 Constraint::Min(0),
164 Constraint::Length(1),
165 ])
166 .split(inner);
167
168 let prefix = "› ";
170 f.render_widget(
171 Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
172 rows[0],
173 );
174 let input_rect = Rect {
175 x: rows[0].x + 2,
176 width: rows[0].width.saturating_sub(2),
177 ..rows[0]
178 };
179 self.list.render_query(f, input_rect, theme, true);
180
181 self.list.render(f, rows[1], theme, true);
182 self.list.set_list_rect(rows[1]);
183 self.list.set_panel_rect(popup);
184
185 f.render_widget(
186 Paragraph::new(Line::from(Span::styled(
187 "↑↓ move · ⏎ run · Esc close",
188 Style::default().fg(theme.gray.to_ratatui()),
189 ))),
190 rows[2],
191 );
192
193 self.list.render_autocomplete(f, popup, theme);
194 }
195
196 fn hint_shortcuts(&self) -> Vec<(String, String)> {
197 vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn entries_cover_every_leader_leaf() {
207 let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
208 assert!(entries.len() > 20);
210 assert!(
211 entries
212 .iter()
213 .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
214 );
215 assert!(
216 entries
217 .iter()
218 .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
219 );
220 }
221
222 #[tokio::test(flavor = "multi_thread")]
223 async fn enter_closes_then_executes() {
224 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
225 let mut palette = CommandPaletteModal::new(
226 &crate::keys::leader::leader_tree(),
227 "Ctrl+G",
228 Icons::new(false),
229 tx.clone(),
230 );
231 for _ in 0..50 {
233 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
234 palette.list.poll();
235 }
236 assert!(palette.list.selected_row().is_some());
237
238 palette.handle_input(
239 &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
240 ratatui::crossterm::event::KeyCode::Enter,
241 ratatui::crossterm::event::KeyModifiers::NONE,
242 )),
243 &tx,
244 );
245
246 let mut order = Vec::new();
247 while let Ok(ev) = rx.try_recv() {
248 match ev {
249 AppEvent::CloseOverlay => order.push("close"),
250 AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
251 _ => {}
252 }
253 }
254 assert_eq!(order, vec!["close", "execute"]);
255 }
256}