ui/components/ai/
completion_popover.rs1use std::rc::Rc;
2
3use gpui::{Anchor, AnyElement, Bounds, Pixels, anchored, deferred, point};
4
5use crate::prelude::*;
6use crate::score;
7
8#[derive(Clone, Debug, PartialEq)]
12pub struct CompletionItem {
13 pub label: SharedString,
14 pub description: Option<SharedString>,
15 pub insert_text: SharedString,
16}
17
18impl CompletionItem {
19 pub fn new(label: impl Into<SharedString>, insert_text: impl Into<SharedString>) -> Self {
20 Self {
21 label: label.into(),
22 description: None,
23 insert_text: insert_text.into(),
24 }
25 }
26
27 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
28 self.description = Some(description.into());
29 self
30 }
31}
32
33pub fn filter_completions<'a>(items: &'a [CompletionItem], query: &str) -> Vec<&'a CompletionItem> {
37 let mut matches: Vec<(&CompletionItem, i32)> = items
38 .iter()
39 .filter_map(|item| score(query, item.label.as_ref()).map(|s| (item, s)))
40 .collect();
41 matches.sort_by(|a, b| b.1.cmp(&a.1));
42 matches.into_iter().map(|(item, _)| item).collect()
43}
44
45pub struct CompletionPopover {
57 items: Vec<CompletionItem>,
58 query: SharedString,
59 selected_ix: usize,
60 anchor: Bounds<Pixels>,
61 width: Pixels,
62 on_select: Rc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
63}
64
65impl CompletionPopover {
66 pub fn new(
67 items: Vec<CompletionItem>,
68 query: impl Into<SharedString>,
69 selected_ix: usize,
70 anchor: Bounds<Pixels>,
71 on_select: impl Fn(SharedString, &mut Window, &mut App) + 'static,
72 ) -> Self {
73 Self {
74 items,
75 query: query.into(),
76 selected_ix,
77 anchor,
78 width: px(280.),
79 on_select: Rc::new(on_select),
80 }
81 }
82
83 pub fn width(mut self, width: Pixels) -> Self {
84 self.width = width;
85 self
86 }
87
88 pub fn render(self, cx: &mut App) -> AnyElement {
91 let matched = filter_completions(&self.items, &self.query);
92 let selected_ix = self.selected_ix.min(matched.len().saturating_sub(1));
93 let hover = semantic::hover_bg(cx);
94
95 let mut list = v_flex()
96 .id("completion-popover-list")
97 .w(self.width)
98 .max_h(px(240.))
99 .overflow_y_scroll()
100 .p_1()
101 .gap_0p5()
102 .rounded_md()
103 .bg(semantic::elevated_surface(cx))
104 .border_1()
105 .border_color(semantic::border(cx))
106 .shadow_level(Shadow::Lg);
107
108 if matched.is_empty() {
109 list = list.child(
110 div()
111 .px_3()
112 .py_2()
113 .child(Label::new("No matching commands").color(Color::Muted)),
114 );
115 }
116
117 for (ix, item) in matched.into_iter().enumerate() {
118 let insert_text = item.insert_text.clone();
119 let on_select = self.on_select.clone();
120 list = list.child(
121 v_flex()
122 .id(("completion-popover-item", ix))
123 .w_full()
124 .px_3()
125 .py_1p5()
126 .gap_0p5()
127 .rounded_md()
128 .cursor_pointer()
129 .when(ix == selected_ix, |this| this.bg(hover))
130 .hover(move |style| style.bg(hover))
131 .on_click(move |_, window, cx| on_select(insert_text.clone(), window, cx))
132 .child(Label::new(item.label.clone()))
133 .when_some(item.description.clone(), |this, description| {
134 this.child(
135 Label::new(description)
136 .size(LabelSize::Small)
137 .color(Color::Muted),
138 )
139 }),
140 );
141 }
142
143 deferred(
144 anchored()
145 .snap_to_window_with_margin(px(8.))
146 .anchor(Anchor::BottomLeft)
147 .position(point(self.anchor.origin.x, self.anchor.origin.y - px(4.)))
148 .child(div().occlude().child(list)),
149 )
150 .with_priority(1)
151 .into_any_element()
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 fn items() -> Vec<CompletionItem> {
160 vec![
161 CompletionItem::new("help", "/help"),
162 CompletionItem::new("explain", "/explain"),
163 CompletionItem::new("tests", "/tests"),
164 ]
165 }
166
167 #[test]
168 fn empty_query_returns_all_items_in_order() {
169 let items = items();
170 let matched = filter_completions(&items, "");
171 assert_eq!(matched.len(), 3);
172 assert_eq!(matched[0].label.as_ref(), "help");
173 }
174
175 #[test]
176 fn query_filters_by_label_subsequence() {
177 let items = items();
178 let matched = filter_completions(&items, "exp");
179 assert_eq!(matched.len(), 1);
180 assert_eq!(matched[0].label.as_ref(), "explain");
181 }
182
183 #[test]
184 fn no_match_returns_empty() {
185 let items = items();
186 assert!(filter_completions(&items, "zzz").is_empty());
187 }
188}