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