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 let mut line = Line::with_style(truncated, ctx.theme.selected_row_style());
72 line.extend_bg_to_width(max_width);
73 line
74 } else {
75 build_styled_command_line(&truncated, padded_name.len(), ctx.theme.muted())
76 }
77 });
78 lines.extend(item_lines);
79
80 Frame::new(lines)
81 }
82}
83
84fn build_styled_command_line(truncated: &str, name_byte_len: usize, muted: tui::Color) -> Line {
85 if truncated.len() <= name_byte_len {
86 Line::new(truncated)
87 } else {
88 let mut line = Line::new(&truncated[..name_byte_len]);
89 line.push_with_style(&truncated[name_byte_len..], Style::fg(muted));
90 line
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use tui::test_picker::type_query;
98 use tui::{KeyCode, KeyEvent, KeyModifiers};
99
100 fn key(code: KeyCode) -> KeyEvent {
101 KeyEvent::new(code, KeyModifiers::NONE)
102 }
103
104 fn sample_commands() -> Vec<CommandEntry> {
105 vec![
106 CommandEntry {
107 name: "settings".into(),
108 description: "Open settings".into(),
109 has_input: false,
110 hint: None,
111 builtin: true,
112 },
113 CommandEntry {
114 name: "search".into(),
115 description: "Search code in the project".into(),
116 has_input: true,
117 hint: Some("query pattern".into()),
118 builtin: false,
119 },
120 CommandEntry {
121 name: "web".into(),
122 description: "Browse the web".into(),
123 has_input: true,
124 hint: Some("url".into()),
125 builtin: false,
126 },
127 ]
128 }
129
130 #[tokio::test]
131 async fn handle_key_enter_returns_selected_command() {
132 let mut picker = CommandPicker::new(sample_commands());
133
134 let outcome = picker.on_event(&Event::Key(key(KeyCode::Enter))).await;
135
136 assert!(outcome.is_some());
137 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::Confirm(_)]));
138 }
139
140 #[tokio::test]
141 async fn handle_key_backspace_on_empty_query_requests_close() {
142 let mut picker = CommandPicker::new(sample_commands());
143
144 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
145
146 assert!(outcome.is_some());
147
148 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseAndPopChar]));
149 }
150
151 #[tokio::test]
152 async fn handle_key_char_returns_char_typed() {
153 let mut picker = CommandPicker::new(sample_commands());
154
155 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char('r')))).await;
156
157 assert!(outcome.is_some());
158
159 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CharTyped('r')]));
160 assert_eq!(picker.query(), "r");
161 }
162
163 #[tokio::test]
164 async fn handle_key_whitespace_closes_picker() {
165 let mut picker = CommandPicker::new(sample_commands());
166
167 let outcome = picker.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
168
169 assert!(outcome.is_some());
170
171 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseWithChar(' ')]));
172 }
173
174 #[tokio::test]
175 async fn handle_key_backspace_with_query_returns_pop_char() {
176 let mut picker = CommandPicker::new(sample_commands());
177 type_query(&mut picker, "co").await;
178
179 let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
180
181 assert!(outcome.is_some());
182
183 assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::PopChar]));
184 assert_eq!(picker.query(), "c");
185 }
186
187 #[tokio::test]
188 async fn type_and_delete_updates_query() {
189 let mut picker = CommandPicker::new(sample_commands());
190 type_query(&mut picker, "co").await;
191 assert_eq!(picker.query(), "co");
192
193 picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
194 assert_eq!(picker.query(), "c");
195
196 picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
197 assert_eq!(picker.query(), "");
198 }
199}