Skip to main content

wisp/surfaces/
picker.rs

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