Skip to main content

gpui_base/
combobox.rs

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/// An unstyled controlled combobox root.
38///
39/// Applications own the trigger, popup, searchable collection, selection, and
40/// appearance. This root owns combobox semantics and keyboard focus transfer.
41#[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    /// Handles the Confirm action while the combobox is open.
100    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    /// Handles a dismissal requested through the Cancel action.
106    ///
107    /// This runs before the controlled open state is asked to close, so a
108    /// combobox that commits its pending value on dismissal can still read
109    /// that value here.
110    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            // Dismissal runs before the close request so a caller can still
229            // commit its pending value.
230            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}