Skip to main content

wisp/surfaces/
picker.rs

1use crate::attachment::PromptAttachment;
2use crate::view::filterable_list::FilterableList;
3use crate::request::RequestId;
4use crate::view::list_view::ListView;
5use crate::view::selection::SelectionState;
6use crate::theme::Theme;
7use ratatui::text::Line;
8use ratatui::widgets::{Block, Borders};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct CommandEntry {
12    pub name: String,
13    pub description: String,
14    pub has_input: bool,
15    pub hint: Option<String>,
16    pub builtin: bool,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CompletionEntry {
21    Command(CommandEntry),
22    File(PromptAttachment),
23}
24
25/// The inline completion list the composer shows after a `/` or `@` trigger.
26#[derive(Debug, Clone)]
27pub struct CompletionOverlay {
28    trigger: char,
29    entries: FilterableList<CompletionEntry>,
30    empty_message: &'static str,
31    /// The file-index request this overlay is waiting on, if any.
32    pending_index: Option<RequestId>,
33}
34
35impl CompletionOverlay {
36    pub fn command(entries: Vec<CommandEntry>) -> Self {
37        Self::new('/', entries.into_iter().map(CompletionEntry::Command).collect(), "no matching commands")
38    }
39
40    /// An empty file list, waiting on the walk of the working tree. Entries
41    /// arrive later via [`CompletionOverlay::set_files`].
42    pub fn file(request_id: RequestId) -> Self {
43        let mut overlay = Self::new('@', Vec::new(), "indexing files…");
44        overlay.pending_index = Some(request_id);
45        overlay
46    }
47
48    /// Fills in the results of the walk this overlay is waiting on, ignoring
49    /// anything that arrives for a request it did not make.
50    pub fn set_files(&mut self, request_id: RequestId, files: Vec<PromptAttachment>) {
51        if self.pending_index != Some(request_id) {
52            return;
53        }
54        self.pending_index = None;
55        self.empty_message = "no matching files";
56        let query = self.entries.query().to_string();
57        self.entries =
58            FilterableList::new(files.into_iter().map(CompletionEntry::File).collect(), CompletionEntry::match_key);
59        self.entries.set_query(query);
60    }
61
62    /// The character that opened this overlay, and the one whose token the
63    /// composer replaces on accept.
64    pub fn trigger(&self) -> char {
65        self.trigger
66    }
67
68    pub fn query(&self) -> &str {
69        self.entries.query()
70    }
71
72    pub fn set_query(&mut self, query: String) {
73        self.entries.set_query(query);
74    }
75
76    pub fn entries_mut(&mut self) -> &mut FilterableList<CompletionEntry> {
77        &mut self.entries
78    }
79
80    pub fn selected_command(&self) -> Option<CommandEntry> {
81        match self.entries.selected_entry()? {
82            CompletionEntry::Command(command) => Some(command.clone()),
83            CompletionEntry::File(_) => None,
84        }
85    }
86
87    pub fn selected_file(&self) -> Option<PromptAttachment> {
88        match self.entries.selected_entry()? {
89            CompletionEntry::File(file) => Some(file.clone()),
90            CompletionEntry::Command(_) => None,
91        }
92    }
93
94    /// Rows the overlay occupies above the composer: a rule plus either the
95    /// visible matches or the single "no matches" placeholder.
96    pub fn row_count(&self, max_rows: usize) -> usize {
97        1 + self.entries.filtered_len().clamp(1, max_rows.max(1))
98    }
99
100    pub fn view<'a>(&'a mut self, theme: &'a Theme) -> (ListView<'a>, &'a mut SelectionState) {
101        let empty_message = self.empty_message;
102        let (view, selection) = self
103            .entries
104            .view(theme, |entry| Line::styled(format!("  {}", entry.label()), theme.text_secondary));
105        let view = view
106            .empty_message(empty_message)
107            .block(Block::new().borders(Borders::TOP).border_style(theme.muted));
108        (view, selection)
109    }
110
111    fn new(trigger: char, entries: Vec<CompletionEntry>, empty_message: &'static str) -> Self {
112        Self {
113            trigger,
114            entries: FilterableList::new(entries, CompletionEntry::match_key),
115            empty_message,
116            pending_index: None,
117        }
118    }
119}
120
121impl CompletionEntry {
122    fn label(&self) -> String {
123        match self {
124            Self::Command(command) => {
125                let hint = command.hint.as_deref().map_or_else(String::new, |hint| format!("  [{hint}]"));
126                format!("/{:<16}  {}{hint}", command.name, command.description)
127            }
128            Self::File(file) => file.display_name.clone(),
129        }
130    }
131
132    fn match_key(&self) -> String {
133        match self {
134            Self::Command(command) => format!("{} {}", command.name, command.description),
135            Self::File(file) => file.display_name.clone(),
136        }
137    }
138}