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