Skip to main content

wisp/components/
command_picker.rs

1use 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}