ui/components/
combobox.rs1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4 AnyElement, Bounds, Context, Entity, Pixels, Render, anchored, canvas, deferred, point,
5};
6
7use crate::TextInput;
8use crate::prelude::*;
9use crate::utils::fuzzy_subsequence_score;
10
11pub struct Combobox {
19 options: Vec<SharedString>,
20 selected: Option<usize>,
21 open: bool,
22 fuzzy_filter: bool,
23 input: Entity<TextInput>,
24 trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
29}
30
31impl Combobox {
32 pub fn new(
33 cx: &mut Context<Self>,
34 options: impl IntoIterator<Item = impl Into<SharedString>>,
35 ) -> Self {
36 let input = cx.new(|cx| TextInput::new(cx).placeholder("Search…"));
37 cx.observe(&input, |_, _, cx| cx.notify()).detach();
38 Self {
39 options: options.into_iter().map(Into::into).collect(),
40 selected: None,
41 open: false,
42 fuzzy_filter: false,
43 input,
44 trigger_bounds: Rc::new(Cell::new(None)),
45 }
46 }
47
48 pub fn value(&self) -> Option<&SharedString> {
50 self.selected.and_then(|i| self.options.get(i))
51 }
52
53 pub fn fuzzy_filter(mut self, fuzzy_filter: bool) -> Self {
55 self.fuzzy_filter = fuzzy_filter;
56 self
57 }
58
59 fn filtered(&self, cx: &App) -> Vec<(usize, SharedString)> {
61 let query = self.input.read(cx).text();
62 if query.is_empty() {
63 return self
64 .options
65 .iter()
66 .enumerate()
67 .map(|(i, option)| (i, option.clone()))
68 .collect();
69 }
70
71 if self.fuzzy_filter {
72 let mut matches: Vec<(usize, usize, SharedString)> = self
73 .options
74 .iter()
75 .enumerate()
76 .filter_map(|(i, option)| {
77 fuzzy_subsequence_score(query, option).map(|score| (i, score, option.clone()))
78 })
79 .collect();
80 matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
81 matches
82 .into_iter()
83 .map(|(i, _, option)| (i, option))
84 .collect()
85 } else {
86 let query_lower = query.to_lowercase();
87 self.options
88 .iter()
89 .enumerate()
90 .filter(|(_, option)| {
91 query_lower.is_empty() || option.to_lowercase().contains(&query_lower)
92 })
93 .map(|(i, option)| (i, option.clone()))
94 .collect()
95 }
96 }
97}
98
99impl Render for Combobox {
100 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
101 let open = self.open;
102 let filtered = self.filtered(cx);
103
104 let trigger = h_flex()
105 .id("combobox-trigger")
106 .debug_selector(|| "COMBOBOX-TRIGGER".into())
110 .w_full()
111 .items_center()
112 .justify_between()
113 .px_3()
114 .py_2()
115 .rounded_md()
116 .bg(semantic::surface(cx))
117 .border_1()
118 .border_color(if open {
119 palette::primary(500)
120 } else {
121 semantic::border(cx)
122 })
123 .child(div().flex_1().min_w_0().child(self.input.clone()))
124 .child(
125 div()
126 .id("combobox-toggle")
127 .debug_selector(|| "COMBOBOX-TOGGLE".into())
132 .cursor_pointer()
133 .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
134 .on_click(cx.listener(|this, _, _, cx| {
135 this.open = !this.open;
136 cx.notify();
137 })),
138 )
139 .child({
140 let trigger_bounds = self.trigger_bounds.clone();
141 canvas(
142 move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
143 |_bounds, _state, _window, _cx| {},
144 )
145 .absolute()
146 .top_0()
147 .left_0()
148 .size_full()
149 });
150
151 let trigger_width = px(240.);
152
153 v_flex()
154 .w(trigger_width)
155 .gap_1()
156 .child(trigger)
157 .when(open, |this| {
158 let hover = semantic::hover_bg(cx);
159 let mut list = v_flex()
160 .w(trigger_width)
161 .p_1()
162 .rounded_md()
163 .bg(semantic::elevated_surface(cx))
164 .border_1()
165 .border_color(semantic::border(cx))
166 .shadow_level(Shadow::Lg);
167
168 if filtered.is_empty() {
169 list = list.child(
170 div()
171 .px_3()
172 .py_2()
173 .child(Label::new("No matches").color(Color::Muted)),
174 );
175 }
176
177 for (i, option) in filtered {
178 let label = option.clone();
179 list = list.child(
180 h_flex()
181 .id(("combobox-option", i))
182 .w_full()
183 .px_3()
184 .py_2()
185 .rounded_md()
186 .cursor_pointer()
187 .hover(move |s| s.bg(hover))
188 .on_click(cx.listener(move |this, _, _, cx| {
189 this.selected = Some(i);
190 this.open = false;
191 let label = label.clone();
192 this.input
193 .update(cx, |input, cx| input.set_text(label.to_string(), cx));
194 cx.notify();
195 }))
196 .child(Label::new(option)),
197 );
198 }
199
200 let mut anchor = anchored().snap_to_window_with_margin(px(8.));
207 if let Some(bounds) = self.trigger_bounds.get() {
208 anchor = anchor.position(point(
209 bounds.origin.x,
210 bounds.origin.y + bounds.size.height + px(4.),
211 ));
212 }
213 let floating_list = deferred(
214 anchor.child(
215 div()
216 .occlude()
217 .debug_selector(|| "COMBOBOX-LIST".into())
218 .child(list),
219 ),
220 )
221 .with_priority(1);
222 this.child(floating_list)
223 })
224 }
225}
226
227pub fn combobox_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
231 v_flex()
232 .gap_4()
233 .child(cx.new(|cx| Combobox::new(cx, ["Apple", "Banana", "Cherry", "Date", "Elderberry"])))
234 .into_any_element()
235}