Skip to main content

wisp/components/
command_picker.rs

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