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 {
66 action: LeaderAction::PinnedJump(_),
67 ..
68 } => {}
69 LeaderNode::Leaf { label, action } => {
70 let label = if group.is_empty() {
71 (*label).to_string()
72 } else {
73 format!("{group} · {label}")
74 };
75 out.push(CommandEntry {
76 haystack: format!("{label} {child_keys}"),
77 label,
78 keys: child_keys,
79 action: *action,
80 });
81 }
82 LeaderNode::Group { label, .. } => walk(child, label, &child_keys, out),
83 }
84 }
85 }
86 let mut out = Vec::new();
87 walk(tree, "", gateway, &mut out);
88 out
89}
90
91pub struct CommandPaletteModal {
93 list: SearchList<CommandEntry>,
94}
95
96impl CommandPaletteModal {
97 pub fn new(tree: &LeaderNode, gateway: &str, icons: Icons, tx: AppTx) -> Self {
98 let list = SearchList::builder(StaticRowSource, redraw_callback(tx))
101 .filter(Filter::Fuzzy)
102 .icons(icons)
103 .build_with_rows(command_entries(tree, gateway));
104 Self { list }
105 }
106
107 fn execute_selected(&self, tx: &AppTx) {
108 if let Some(entry) = self.list.selected_row() {
109 let action = entry.action;
110 tx.send(AppEvent::CloseOverlay).ok();
113 tx.send(AppEvent::ExecuteLeaderAction(action)).ok();
114 }
115 }
116}
117
118impl Overlay for CommandPaletteModal {
119 fn kind(&self) -> OverlayKind {
120 OverlayKind::CommandPalette
121 }
122
123 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
124 match event {
125 InputEvent::Key(key) => match self.list.handle_key(key) {
126 KeyReaction::Submit => {
127 self.execute_selected(tx);
128 EventState::Consumed
129 }
130 KeyReaction::Cancel => {
131 tx.send(AppEvent::CloseOverlay).ok();
132 EventState::Consumed
133 }
134 KeyReaction::Yank(target) => {
138 crate::components::yank_row(target, tx);
139 EventState::Consumed
140 }
141 _ => EventState::Consumed,
142 },
143 InputEvent::Mouse(mouse) => {
144 if let SearchMouse::Activated(_) = self.list.handle_mouse(mouse) {
145 self.execute_selected(tx);
146 }
147 EventState::Consumed
148 }
149 _ => EventState::NotConsumed,
150 }
151 }
152
153 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
154 let popup = crate::components::centered_rect(60, 60, area);
155 let inner = modal_chrome(
156 f,
157 popup,
158 theme,
159 ModalSpec {
160 title: Some(" Commands "),
161 bg: ModalBg::Hard,
162 ..Default::default()
163 },
164 );
165
166 let rows = Layout::default()
167 .direction(Direction::Vertical)
168 .constraints([
169 Constraint::Length(1),
170 Constraint::Min(0),
171 Constraint::Length(1),
172 ])
173 .split(inner);
174
175 let prefix = "› ";
177 f.render_widget(
178 Paragraph::new(prefix).style(Style::default().fg(theme.yellow.to_ratatui())),
179 rows[0],
180 );
181 let input_rect = Rect {
182 x: rows[0].x + 2,
183 width: rows[0].width.saturating_sub(2),
184 ..rows[0]
185 };
186 self.list.render_query(f, input_rect, theme, true);
187
188 self.list.render(f, rows[1], theme, true);
189 self.list.set_list_rect(rows[1]);
190 self.list.set_panel_rect(popup);
191
192 f.render_widget(
193 Paragraph::new(Line::from(Span::styled(
194 "↑↓ move · ⏎ run · Esc close",
195 Style::default().fg(theme.gray.to_ratatui()),
196 ))),
197 rows[2],
198 );
199
200 self.list.render_autocomplete(f, popup, theme);
201 }
202
203 fn hint_shortcuts(&self) -> Vec<(String, String)> {
204 vec![("↑↓".into(), "move".into()), ("Enter".into(), "run".into())]
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn entries_cover_every_leader_leaf() {
214 let entries = command_entries(&crate::keys::leader::leader_tree(), "Ctrl+G");
215 assert!(entries.len() > 20);
217 assert!(
218 entries
219 .iter()
220 .any(|e| e.keys == "Ctrl+G o f" && e.label.contains("files"))
221 );
222 assert!(
223 entries
224 .iter()
225 .any(|e| e.action == LeaderAction::Help && e.keys == "Ctrl+G ?")
226 );
227 assert!(
228 !entries
229 .iter()
230 .any(|e| matches!(e.action, LeaderAction::PinnedJump(_))),
231 "the nine jump leaves stay out of the palette"
232 );
233 assert!(entries.iter().any(|e| e.action == LeaderAction::FindPinned));
234 assert!(
235 entries
236 .iter()
237 .any(|e| e.action == LeaderAction::NoteTogglePin)
238 );
239 }
240
241 #[tokio::test(flavor = "multi_thread")]
242 async fn enter_closes_then_executes() {
243 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
244 let mut palette = CommandPaletteModal::new(
245 &crate::keys::leader::leader_tree(),
246 "Ctrl+G",
247 Icons::new(false),
248 tx.clone(),
249 );
250 for _ in 0..50 {
252 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
253 palette.list.poll();
254 }
255 assert!(palette.list.selected_row().is_some());
256
257 palette.handle_input(
258 &InputEvent::Key(ratatui::crossterm::event::KeyEvent::new(
259 ratatui::crossterm::event::KeyCode::Enter,
260 ratatui::crossterm::event::KeyModifiers::NONE,
261 )),
262 &tx,
263 );
264
265 let mut order = Vec::new();
266 while let Ok(ev) = rx.try_recv() {
267 match ev {
268 AppEvent::CloseOverlay => order.push("close"),
269 AppEvent::ExecuteLeaderAction(_) => order.push("execute"),
270 _ => {}
271 }
272 }
273 assert_eq!(order, vec!["close", "execute"]);
274 }
275}