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::*;
9
10pub struct Combobox {
18 options: Vec<SharedString>,
19 selected: Option<usize>,
20 open: bool,
21 input: Entity<TextInput>,
22 trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
27}
28
29impl Combobox {
30 pub fn new(
31 cx: &mut Context<Self>,
32 options: impl IntoIterator<Item = impl Into<SharedString>>,
33 ) -> Self {
34 let input = cx.new(|cx| TextInput::new(cx).placeholder("Search…"));
35 cx.observe(&input, |_, _, cx| cx.notify()).detach();
36 Self {
37 options: options.into_iter().map(Into::into).collect(),
38 selected: None,
39 open: false,
40 input,
41 trigger_bounds: Rc::new(Cell::new(None)),
42 }
43 }
44
45 pub fn value(&self) -> Option<&SharedString> {
47 self.selected.and_then(|i| self.options.get(i))
48 }
49
50 fn filtered(&self, cx: &App) -> Vec<(usize, SharedString)> {
52 let query = self.input.read(cx).text().to_lowercase();
53 self.options
54 .iter()
55 .enumerate()
56 .filter(|(_, option)| query.is_empty() || option.to_lowercase().contains(&query))
57 .map(|(i, option)| (i, option.clone()))
58 .collect()
59 }
60}
61
62impl Render for Combobox {
63 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
64 let open = self.open;
65 let filtered = self.filtered(cx);
66
67 let trigger = h_flex()
68 .id("combobox-trigger")
69 .debug_selector(|| "COMBOBOX-TRIGGER".into())
73 .w_full()
74 .items_center()
75 .justify_between()
76 .px_3()
77 .py_2()
78 .rounded_md()
79 .bg(semantic::surface(cx))
80 .border_1()
81 .border_color(if open {
82 palette::primary(500)
83 } else {
84 semantic::border(cx)
85 })
86 .child(div().flex_1().min_w_0().child(self.input.clone()))
87 .child(
88 div()
89 .id("combobox-toggle")
90 .debug_selector(|| "COMBOBOX-TOGGLE".into())
95 .cursor_pointer()
96 .child(Icon::new(IconName::ChevronDown).size(IconSize::Small))
97 .on_click(cx.listener(|this, _, _, cx| {
98 this.open = !this.open;
99 cx.notify();
100 })),
101 )
102 .child({
103 let trigger_bounds = self.trigger_bounds.clone();
104 canvas(
105 move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
106 |_bounds, _state, _window, _cx| {},
107 )
108 .absolute()
109 .top_0()
110 .left_0()
111 .size_full()
112 });
113
114 let trigger_width = px(240.);
115
116 v_flex()
117 .w(trigger_width)
118 .gap_1()
119 .child(trigger)
120 .when(open, |this| {
121 let hover = semantic::hover_bg(cx);
122 let mut list = v_flex()
123 .w(trigger_width)
124 .p_1()
125 .rounded_md()
126 .bg(semantic::elevated_surface(cx))
127 .border_1()
128 .border_color(semantic::border(cx))
129 .shadow_level(Shadow::Lg);
130
131 if filtered.is_empty() {
132 list = list.child(
133 div()
134 .px_3()
135 .py_2()
136 .child(Label::new("No matches").color(Color::Muted)),
137 );
138 }
139
140 for (i, option) in filtered {
141 let label = option.clone();
142 list = list.child(
143 h_flex()
144 .id(("combobox-option", i))
145 .w_full()
146 .px_3()
147 .py_2()
148 .rounded_md()
149 .cursor_pointer()
150 .hover(move |s| s.bg(hover))
151 .on_click(cx.listener(move |this, _, _, cx| {
152 this.selected = Some(i);
153 this.open = false;
154 let label = label.clone();
155 this.input
156 .update(cx, |input, cx| input.set_text(label.to_string(), cx));
157 cx.notify();
158 }))
159 .child(Label::new(option)),
160 );
161 }
162
163 let mut anchor = anchored().snap_to_window_with_margin(px(8.));
170 if let Some(bounds) = self.trigger_bounds.get() {
171 anchor = anchor.position(point(
172 bounds.origin.x,
173 bounds.origin.y + bounds.size.height + px(4.),
174 ));
175 }
176 let floating_list = deferred(
177 anchor.child(
178 div()
179 .occlude()
180 .debug_selector(|| "COMBOBOX-LIST".into())
181 .child(list),
182 ),
183 )
184 .with_priority(1);
185 this.child(floating_list)
186 })
187 }
188}
189
190pub fn combobox_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
194 v_flex()
195 .gap_4()
196 .child(cx.new(|cx| Combobox::new(cx, ["Apple", "Banana", "Cherry", "Date", "Elderberry"])))
197 .into_any_element()
198}