1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, ElementId, FocusHandle, IntoElement, KeyBinding, ParentElement, RenderOnce,
5 StyleRefinement, Styled, Window, prelude::FluentBuilder as _,
6};
7
8use crate::{
9 Select,
10 actions::{Cancel, Confirm, SelectDown, SelectUp},
11 styled::StyledExt as _,
12};
13
14const CONTEXT: &str = "Combobox";
15
16pub(crate) fn init(cx: &mut App) {
17 cx.bind_keys([
18 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
19 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
20 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
21 KeyBinding::new(
22 if cx.platform_info().is_mac() {
23 "cmd-enter"
24 } else {
25 "ctrl-enter"
26 },
27 Confirm { secondary: true },
28 Some(CONTEXT),
29 ),
30 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
31 ]);
32}
33
34type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
35type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
36
37#[derive(IntoElement)]
42pub struct Combobox {
43 id: ElementId,
44 open: bool,
45 disabled: bool,
46 focus_handle: Option<FocusHandle>,
47 content_focus_handle: Option<FocusHandle>,
48 style: StyleRefinement,
49 children: Vec<AnyElement>,
50 on_open_change: Option<OpenChangeHandler>,
51 on_confirm: Option<ActionHandler>,
52 on_dismiss: Option<ActionHandler>,
53}
54
55impl Combobox {
56 pub fn new(id: impl Into<ElementId>) -> Self {
57 Self {
58 id: id.into(),
59 open: false,
60 disabled: false,
61 focus_handle: None,
62 content_focus_handle: None,
63 style: StyleRefinement::default(),
64 children: Vec::new(),
65 on_open_change: None,
66 on_confirm: None,
67 on_dismiss: None,
68 }
69 }
70
71 pub fn open(mut self, open: bool) -> Self {
72 self.open = open;
73 self
74 }
75
76 pub fn disabled(mut self, disabled: bool) -> Self {
77 self.disabled = disabled;
78 self
79 }
80
81 pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
82 self.focus_handle = Some(focus_handle.clone());
83 self
84 }
85
86 pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
87 self.content_focus_handle = Some(focus_handle.clone());
88 self
89 }
90
91 pub fn on_open_change(
92 mut self,
93 handler: impl Fn(bool, &mut Window, &mut App) + 'static,
94 ) -> Self {
95 self.on_open_change = Some(Rc::new(handler));
96 self
97 }
98
99 pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
101 self.on_confirm = Some(Rc::new(handler));
102 self
103 }
104
105 pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
111 self.on_dismiss = Some(Rc::new(handler));
112 self
113 }
114}
115
116impl Styled for Combobox {
117 fn style(&mut self) -> &mut StyleRefinement {
118 &mut self.style
119 }
120}
121
122impl ParentElement for Combobox {
123 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
124 self.children.extend(elements);
125 }
126}
127
128impl RenderOnce for Combobox {
129 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
130 Select::new(self.id)
131 .open(self.open)
132 .disabled(self.disabled)
133 .key_context(CONTEXT)
134 .when_some(self.focus_handle, |this, handle| this.focus_handle(&handle))
135 .when_some(self.content_focus_handle, |this, handle| {
136 this.content_focus_handle(&handle)
137 })
138 .when_some(self.on_open_change, |this, handler| {
139 this.on_open_change(move |open, window, cx| handler(open, window, cx))
140 })
141 .when_some(self.on_confirm, |this, handler| {
142 this.on_confirm(move |window, cx| handler(window, cx))
143 })
144 .when_some(self.on_dismiss, |this, handler| {
145 this.on_dismiss(move |window, cx| handler(window, cx))
146 })
147 .children(self.children)
148 .refine_style(&self.style)
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use gpui::{
156 Context, Focusable, InteractiveElement as _, Render, TestAppContext, VisualTestContext,
157 div, px,
158 };
159 use std::sync::{Arc, Mutex};
160
161 struct Harness {
162 open: bool,
163 trigger_focus: FocusHandle,
164 content_focus: FocusHandle,
165 events: Arc<Mutex<Vec<&'static str>>>,
166 }
167
168 impl Harness {
169 fn new(cx: &mut Context<Self>) -> Self {
170 Self {
171 open: true,
172 trigger_focus: cx.focus_handle(),
173 content_focus: cx.focus_handle(),
174 events: Arc::new(Mutex::new(Vec::new())),
175 }
176 }
177 }
178
179 impl Focusable for Harness {
180 fn focus_handle(&self, _: &App) -> FocusHandle {
181 self.trigger_focus.clone()
182 }
183 }
184
185 impl Render for Harness {
186 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
187 let state = cx.entity();
188 let confirm_events = self.events.clone();
189 let dismiss_events = self.events.clone();
190 let open_events = self.events.clone();
191
192 Combobox::new("combobox")
193 .open(self.open)
194 .focus_handle(&self.trigger_focus)
195 .content_focus_handle(&self.content_focus)
196 .on_confirm(move |_, _| confirm_events.lock().unwrap().push("confirm"))
197 .on_dismiss(move |_, _| dismiss_events.lock().unwrap().push("dismiss"))
198 .on_open_change(move |open, _, cx| {
199 open_events.lock().unwrap().push("close");
200 state.update(cx, |state, cx| {
201 state.open = open;
202 cx.notify();
203 });
204 })
205 .child(div().track_focus(&self.content_focus).size(px(20.)))
206 }
207 }
208
209 fn harness(cx: &mut TestAppContext) -> (&mut VisualTestContext, gpui::Entity<Harness>) {
210 cx.update(crate::init);
211 let (state, cx) = cx.add_window_view(|_, cx| Harness::new(cx));
212 cx.update(|window, cx| {
213 let content_focus = state.read(cx).content_focus.clone();
214 content_focus.focus(window, cx);
215 window.draw(cx).clear(cx);
216 });
217 (cx, state)
218 }
219
220 #[gpui::test]
221 fn escape_dismisses_then_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
222 let (cx, state) = harness(cx);
223 cx.simulate_keystrokes("escape");
224
225 cx.update(|window, cx| {
226 assert!(!state.read(cx).open);
227 assert!(state.read(cx).trigger_focus.is_focused(window));
228 assert_eq!(
231 state.read(cx).events.lock().unwrap().as_slice(),
232 &["dismiss", "close"]
233 );
234 });
235 }
236
237 #[gpui::test]
238 fn enter_confirms_without_dismissing_while_open(cx: &mut TestAppContext) {
239 let (cx, state) = harness(cx);
240 cx.simulate_keystrokes("enter");
241
242 cx.update(|_, cx| {
243 assert!(state.read(cx).open);
244 assert_eq!(
245 state.read(cx).events.lock().unwrap().as_slice(),
246 &["confirm"]
247 );
248 });
249 }
250}