Skip to main content

gpui_cn/command/
item.rs

1//! The rows a command palette is given: items, groups of them under a
2//! heading, and separators.
3
4use std::rc::Rc;
5
6use gpui_kit::{Action, AnyElement, App, FocusHandle, IntoElement, SharedString, Window};
7
8use crate::Icon;
9
10/// The state of a row a custom renderer draws; see
11/// [`CommandItem::render`].
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct CommandRow {
15    pub(crate) highlighted: bool,
16    pub(crate) disabled: bool,
17}
18
19impl CommandRow {
20    /// Whether the keyboard or the pointer is on the row.
21    pub fn is_highlighted(&self) -> bool {
22        self.highlighted
23    }
24
25    /// Whether the row's command is disabled.
26    pub fn is_disabled(&self) -> bool {
27        self.disabled
28    }
29}
30
31pub(crate) type RowRenderer = Rc<dyn Fn(CommandRow, &mut Window, &mut App) -> AnyElement>;
32
33/// One command: a key the palette reports it by, a label, and what it does.
34#[non_exhaustive]
35pub struct CommandItem {
36    key: SharedString,
37    label: SharedString,
38    /// Boxed: an [`Icon`] carries a whole style, which would make every
39    /// row kilobytes wide.
40    icon: Option<Box<Icon>>,
41    /// Words the query is looked for in besides the label.
42    keywords: Vec<SharedString>,
43    action: Option<Box<dyn Action>>,
44    action_context: Option<FocusHandle>,
45    disabled: bool,
46    render: Option<RowRenderer>,
47}
48
49impl Clone for CommandItem {
50    fn clone(&self) -> Self {
51        Self {
52            key: self.key.clone(),
53            label: self.label.clone(),
54            icon: self.icon.clone(),
55            keywords: self.keywords.clone(),
56            action: self.action.as_ref().map(|action| action.boxed_clone()),
57            action_context: self.action_context.clone(),
58            disabled: self.disabled,
59            render: self.render.clone(),
60        }
61    }
62}
63
64impl CommandItem {
65    /// A command with a key that is stable across renders and a label.
66    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
67        let label = label.into();
68        Self {
69            key: key.into(),
70            keywords: Vec::new(),
71            label,
72            icon: None,
73            action: None,
74            action_context: None,
75            disabled: false,
76            render: None,
77        }
78    }
79
80    /// The icon before the label.
81    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
82        self.icon = Some(Box::new(icon.into()));
83        self
84    }
85
86    /// Words the query matches besides the label, such as "shell" for a
87    /// terminal.
88    pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
89        for keyword in keywords {
90            self.keywords.push(keyword.into());
91        }
92        self
93    }
94
95    /// Dispatches `action` when the command is chosen, and shows the
96    /// action's shortcut at the end of the row.
97    pub fn action(mut self, action: impl Action) -> Self {
98        self.action = Some(Box::new(action));
99        self
100    }
101
102    /// The element the action goes to, and whose key bindings its shortcut
103    /// shows. Without one it goes up from the palette's search field.
104    pub fn action_context(mut self, focus_handle: &FocusHandle) -> Self {
105        self.action_context = Some(focus_handle.clone());
106        self
107    }
108
109    /// Whether the command can be chosen. A disabled row shows muted and
110    /// the keyboard passes over it.
111    pub fn disabled(mut self, disabled: bool) -> Self {
112        self.disabled = disabled;
113        self
114    }
115
116    /// Draws the inside of the row in place of the icon, the label, and
117    /// the shortcut. The palette keeps the row's frame: its padding, its
118    /// highlight, and the pointer and the keyboard. The label is still
119    /// what the query matches and what assistive technology reads. The
120    /// renderer runs each time the row is drawn, so it must not change
121    /// anything.
122    pub fn render<E: IntoElement>(
123        mut self,
124        render: impl Fn(CommandRow, &mut Window, &mut App) -> E + 'static,
125    ) -> Self {
126        self.render = Some(Rc::new(move |row, window, cx| {
127            render(row, window, cx).into_any_element()
128        }));
129        self
130    }
131
132    /// The key the palette reports this command by.
133    pub fn key(&self) -> &SharedString {
134        &self.key
135    }
136
137    /// The text of the row.
138    pub fn label(&self) -> &SharedString {
139        &self.label
140    }
141
142    /// Whether the command is disabled.
143    pub fn is_disabled(&self) -> bool {
144        self.disabled
145    }
146
147    /// Whether the query matches the label or a keyword, as a fuzzy
148    /// subsequence ignoring case. An empty query matches everything.
149    pub(crate) fn matches(&self, query: &str) -> bool {
150        self.match_score(query).is_some()
151    }
152
153    /// How well the query matches: the label's score, or half the best
154    /// keyword's, which is a weaker hit. `None` for no match.
155    pub(crate) fn match_score(&self, query: &str) -> Option<u32> {
156        let label = crate::fuzzy::score_of(query, &self.label);
157        let keyword = self
158            .keywords
159            .iter()
160            .filter_map(|keyword| crate::fuzzy::score_of(query, keyword))
161            .max()
162            .map(|score| score / 2);
163        label.max(keyword)
164    }
165
166    pub(crate) fn leading_icon(&self) -> Option<&Icon> {
167        self.icon.as_deref()
168    }
169
170    pub(crate) fn dispatched_action(&self) -> Option<&dyn Action> {
171        self.action.as_deref()
172    }
173
174    pub(crate) fn own_action_context(&self) -> Option<&FocusHandle> {
175        self.action_context.as_ref()
176    }
177
178    pub(crate) fn renderer(&self) -> Option<&RowRenderer> {
179        self.render.as_ref()
180    }
181}
182
183/// Commands under a heading. A group with no command left after the query
184/// hides with its heading.
185#[derive(Clone)]
186#[non_exhaustive]
187pub struct CommandGroup {
188    heading: Option<SharedString>,
189    items: Vec<CommandItem>,
190}
191
192impl CommandGroup {
193    /// A group with no heading.
194    pub fn new() -> Self {
195        Self {
196            heading: None,
197            items: Vec::new(),
198        }
199    }
200
201    /// The muted heading over the group's commands.
202    pub fn heading(mut self, heading: impl Into<SharedString>) -> Self {
203        self.heading = Some(heading.into());
204        self
205    }
206
207    /// Adds commands to the group.
208    pub fn items(mut self, items: impl IntoIterator<Item = CommandItem>) -> Self {
209        self.items.extend(items);
210        self
211    }
212}
213
214impl Default for CommandGroup {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220/// One entry of a palette.
221#[derive(Clone)]
222pub enum CommandEntry {
223    /// A command outside any group.
224    Item(CommandItem),
225    /// Commands under a heading.
226    Group(CommandGroup),
227    /// A line between entries. One at either end, or next to another,
228    /// once the query has filtered the rows, is not drawn.
229    Separator,
230}
231
232impl From<CommandItem> for CommandEntry {
233    fn from(item: CommandItem) -> Self {
234        Self::Item(item)
235    }
236}
237
238impl From<CommandGroup> for CommandEntry {
239    fn from(group: CommandGroup) -> Self {
240        Self::Group(group)
241    }
242}
243
244/// A row as the palette draws it, once the query has filtered the
245/// entries.
246#[derive(Clone)]
247pub(crate) enum Row {
248    Heading(SharedString),
249    Separator,
250    Item(CommandItem),
251}
252
253/// The rows of `entries` that match `query`: groups with
254/// no match drop with their heading, and separators keep only between
255/// two runs of rows.
256pub(crate) fn rows(entries: &[CommandEntry], query: &str) -> Vec<Row> {
257    let mut rows = Vec::new();
258    let mut pending_separator = false;
259    let push = |block: Vec<Row>, rows: &mut Vec<Row>, separator: &mut bool| {
260        if block.is_empty() {
261            return;
262        }
263        if *separator && !rows.is_empty() {
264            rows.push(Row::Separator);
265        }
266        *separator = false;
267        rows.extend(block);
268    };
269    for entry in entries {
270        match entry {
271            CommandEntry::Separator => pending_separator = true,
272            CommandEntry::Item(item) => {
273                let block = if item.matches(query) {
274                    vec![Row::Item(item.clone())]
275                } else {
276                    Vec::new()
277                };
278                push(block, &mut rows, &mut pending_separator);
279            }
280            CommandEntry::Group(group) => {
281                let mut hits: Vec<(&CommandItem, u32)> = group
282                    .items
283                    .iter()
284                    .filter_map(|item| Some((item, item.match_score(query)?)))
285                    .collect();
286                hits.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
287                let items: Vec<Row> = hits
288                    .into_iter()
289                    .map(|(item, _)| Row::Item(item.clone()))
290                    .collect();
291                let block = if items.is_empty() {
292                    items
293                } else {
294                    group
295                        .heading
296                        .clone()
297                        .map(Row::Heading)
298                        .into_iter()
299                        .chain(items)
300                        .collect()
301                };
302                push(block, &mut rows, &mut pending_separator);
303            }
304        }
305    }
306    rows
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn shape(rows: &[Row]) -> Vec<String> {
314        rows.iter()
315            .map(|row| match row {
316                Row::Heading(text) => format!("# {text}"),
317                Row::Separator => "--".into(),
318                Row::Item(item) => item.key().to_string(),
319            })
320            .collect()
321    }
322
323    #[test]
324    fn a_query_ranks_the_commands_of_a_group_best_first() {
325        let entries = vec![
326            CommandGroup::new()
327                .heading("Language")
328                .items([
329                    CommandItem::new("de", "Deutsch").keywords(["German"]),
330                    CommandItem::new("en", "English"),
331                ])
332                .into(),
333        ];
334        assert_eq!(shape(&rows(&entries, "")), ["# Language", "de", "en"]);
335        assert_eq!(shape(&rows(&entries, "en")), ["# Language", "en", "de"]);
336    }
337
338    #[test]
339    fn a_query_matches_labels_and_keywords_and_drops_empty_groups() {
340        let entries = vec![
341            CommandItem::new("project", "Project").into(),
342            CommandEntry::Separator,
343            CommandGroup::new()
344                .heading("Open")
345                .items([
346                    CommandItem::new("terminal", "Terminal").keywords(["Shell"]),
347                    CommandItem::new("browser", "Browser"),
348                ])
349                .into(),
350            CommandEntry::Separator,
351            CommandItem::new("desktop", "Desktop").into(),
352        ];
353        assert_eq!(
354            shape(&rows(&entries, "")),
355            [
356                "project", "--", "# Open", "terminal", "browser", "--", "desktop"
357            ]
358        );
359        assert_eq!(shape(&rows(&entries, "shell")), ["# Open", "terminal"]);
360        assert_eq!(
361            shape(&rows(&entries, "o")),
362            ["project", "--", "# Open", "browser", "--", "desktop"]
363        );
364        assert!(rows(&entries, "zzz").is_empty());
365    }
366}