wisp/components/
command_picker.rs1use tui::{
2 Combobox, Component, Event, Frame, Line, PickerMessage, Searchable, Style, ViewContext, display_width_text,
3 pad_text_to_width, truncate_text,
4};
5
6#[derive(Debug, Clone)]
7pub struct CommandEntry {
8 pub name: String,
9 pub description: String,
10 pub has_input: bool,
11 pub hint: Option<String>,
12 pub builtin: bool,
13}
14
15impl Searchable for CommandEntry {
16 fn search_text(&self) -> String {
17 format!("{} {}", self.name, self.description)
18 }
19}
20
21#[doc = include_str!("../docs/command_picker.md")]
22pub struct CommandPicker {
23 combobox: Combobox<CommandEntry>,
24}
25
26pub type CommandPickerMessage = PickerMessage<CommandEntry>;
27
28impl CommandPicker {
29 pub fn new(commands: Vec<CommandEntry>) -> Self {
30 Self { combobox: Combobox::new(commands) }
31 }
32
33 #[cfg(test)]
34 pub fn query(&self) -> &str {
35 self.combobox.query()
36 }
37}
38
39impl Component for CommandPicker {
40 type Message = CommandPickerMessage;
41
42 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
43 self.combobox.handle_picker_event(event)
44 }
45
46 fn render(&mut self, context: &ViewContext) -> Frame {
47 let mut lines = Vec::new();
48
49 if self.combobox.is_empty() {
50 lines.push(Line::new(" (no matching commands)".to_string()));
51 return Frame::new(lines);
52 }
53
54 let max_name_width =
55 self.combobox.matches().iter().map(|cmd| display_width_text(&format!("/{}", cmd.name))).max().unwrap_or(0);
56
57 let item_lines = self.combobox.render_items(context, |command, is_selected, ctx| {
58 let hint_suffix = match &command.hint {
59 Some(hint) => format!(" [{hint}]"),
60 None => String::new(),
61 };
62
63 let name_part = format!("/{}", command.name);
64 let padded_name = pad_text_to_width(&name_part, max_name_width);
65 let line_text = format!("{padded_name} {}{}", command.description, hint_suffix);
66
67 let max_width = ctx.size.width as usize;
68 let truncated = truncate_text(&line_text, max_width);
69
70 if is_selected {
71 ctx.theme.selected_row_line(truncated)
72 } else {
73 build_styled_command_line(&truncated, padded_name.len(), ctx.theme.muted())
74 }
75 });
76 lines.extend(item_lines);
77
78 Frame::new(lines)
79 }
80}
81
82fn build_styled_command_line(truncated: &str, name_byte_len: usize, muted: tui::Color) -> Line {
83 if truncated.len() <= name_byte_len {
84 Line::new(truncated)
85 } else {
86 let mut line = Line::new(&truncated[..name_byte_len]);
87 line.push_with_style(&truncated[name_byte_len..], Style::fg(muted));
88 line
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use tui::test_picker::type_query;
96 use tui::{KeyCode, KeyEvent, KeyModifiers};
97
98 fn key(code: KeyCode) -> KeyEvent {
99 KeyEvent::new(code, KeyModifiers::NONE)
100 }
101
102 fn sample_commands() -> Vec<CommandEntry> {
103 vec![
104 CommandEntry {
105 name: "settings".into(),
106 description: "Open settings".into(),
107 has_input: false,
108 hint: None,
109 builtin: true,
110 },
111 CommandEntry {
112 name: "search".into(),
113 description: "Search code in the project".into(),
114 has_input: true,
115 hint: Some("query pattern".into()),
116 builtin: false,
117 },
118 CommandEntry {
119 name: "web".into(),
120 description: "Browse the web".into(),
121 has_input: true,
122 hint: Some("url".into()),
123 builtin: false,
124 },
125 ]
126 }
127
128 #[tokio::test]
129 async fn handle_key_enter_returns_selected_command() {
130 let mut picker = CommandPicker::new(sample_commands());
131
132 let outcome = picker.on_event(&Event::Key(key(KeyCode::Enter))).await;
133
134 assert!(outcome.is_some());
135 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::Confirm(_)]));
136 }
137
138 #[tokio::test]
139 async fn handle_key_backspace_on_empty_query_requests_close() {
140 let mut picker = CommandPicker::new(sample_commands());
141
142 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
143
144 assert!(outcome.is_some());
145
146 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseAndPopChar]));
147 }
148
149 #[tokio::test]
150 async fn handle_key_char_returns_char_typed() {
151 let mut picker = CommandPicker::new(sample_commands());
152
153 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char('r')))).await;
154
155 assert!(outcome.is_some());
156
157 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CharTyped('r')]));
158 assert_eq!(picker.query(), "r");
159 }
160
161 #[tokio::test]
162 async fn handle_key_whitespace_closes_picker() {
163 let mut picker = CommandPicker::new(sample_commands());
164
165 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
166
167 assert!(outcome.is_some());
168
169 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseWithChar(' ')]));
170 }
171
172 #[tokio::test]
173 async fn handle_key_backspace_with_query_returns_pop_char() {
174 let mut picker = CommandPicker::new(sample_commands());
175 type_query(&mut picker, "co").await;
176
177 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
178
179 assert!(outcome.is_some());
180
181 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::PopChar]));
182 assert_eq!(picker.query(), "c");
183 }
184
185 #[tokio::test]
186 async fn type_and_delete_updates_query() {
187 let mut picker = CommandPicker::new(sample_commands());
188 type_query(&mut picker, "co").await;
189 assert_eq!(picker.query(), "co");
190
191 picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
192 assert_eq!(picker.query(), "c");
193
194 picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
195 assert_eq!(picker.query(), "");
196 }
197}