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            "secondary-enter",
23            Confirm { secondary: true },
24            Some(CONTEXT),
25        ),
26        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
27    ]);
28}
29
30type OpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
31type ActionHandler = Rc<dyn Fn(&mut Window, &mut App)>;
32
33/// An unstyled controlled combobox root.
34///
35/// Applications own the trigger, popup, searchable collection, selection, and
36/// appearance. This root owns combobox semantics and keyboard focus transfer.
37#[derive(IntoElement)]
38pub struct Combobox {
39    id: ElementId,
40    open: bool,
41    disabled: bool,
42    focus_handle: Option<FocusHandle>,
43    content_focus_handle: Option<FocusHandle>,
44    style: StyleRefinement,
45    children: Vec<AnyElement>,
46    on_open_change: Option<OpenChangeHandler>,
47    on_confirm: Option<ActionHandler>,
48    on_dismiss: Option<ActionHandler>,
49}
50
51impl Combobox {
52    pub fn new(id: impl Into<ElementId>) -> Self {
53        Self {
54            id: id.into(),
55            open: false,
56            disabled: false,
57            focus_handle: None,
58            content_focus_handle: None,
59            style: StyleRefinement::default(),
60            children: Vec::new(),
61            on_open_change: None,
62            on_confirm: None,
63            on_dismiss: None,
64        }
65    }
66
67    pub fn open(mut self, open: bool) -> Self {
68        self.open = open;
69        self
70    }
71
72    pub fn disabled(mut self, disabled: bool) -> Self {
73        self.disabled = disabled;
74        self
75    }
76
77    pub fn focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
78        self.focus_handle = Some(focus_handle.clone());
79        self
80    }
81
82    pub fn content_focus_handle(mut self, focus_handle: &FocusHandle) -> Self {
83        self.content_focus_handle = Some(focus_handle.clone());
84        self
85    }
86
87    pub fn on_open_change(
88        mut self,
89        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
90    ) -> Self {
91        self.on_open_change = Some(Rc::new(handler));
92        self
93    }
94
95    /// Handles the Confirm action while the combobox is open.
96    pub fn on_confirm(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
97        self.on_confirm = Some(Rc::new(handler));
98        self
99    }
100
101    /// Handles a dismissal requested through the Cancel action.
102    ///
103    /// This runs before the controlled open state is asked to close, so a
104    /// combobox that commits its pending value on dismissal can still read
105    /// that value here.
106    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
107        self.on_dismiss = Some(Rc::new(handler));
108        self
109    }
110}
111
112impl Styled for Combobox {
113    fn style(&mut self) -> &mut StyleRefinement {
114        &mut self.style
115    }
116}
117
118impl ParentElement for Combobox {
119    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
120        self.children.extend(elements);
121    }
122}
123
124impl RenderOnce for Combobox {
125    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
126        Select::new(self.id)
127            .open(self.open)
128            .disabled(self.disabled)
129            .key_context(CONTEXT)
130            .when_some(self.focus_handle, |this, handle| this.focus_handle(&handle))
131            .when_some(self.content_focus_handle, |this, handle| {
132                this.content_focus_handle(&handle)
133            })
134            .when_some(self.on_open_change, |this, handler| {
135                this.on_open_change(move |open, window, cx| handler(open, window, cx))
136            })
137            .when_some(self.on_confirm, |this, handler| {
138                this.on_confirm(move |window, cx| handler(window, cx))
139            })
140            .when_some(self.on_dismiss, |this, handler| {
141                this.on_dismiss(move |window, cx| handler(window, cx))
142            })
143            .children(self.children)
144            .refine_style(&self.style)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use gpui::{
152        Context, Focusable, InteractiveElement as _, Render, TestAppContext, VisualTestContext,
153        div, px,
154    };
155    use std::sync::{Arc, Mutex};
156
157    struct Harness {
158        open: bool,
159        trigger_focus: FocusHandle,
160        content_focus: FocusHandle,
161        events: Arc<Mutex<Vec<&'static str>>>,
162    }
163
164    impl Harness {
165        fn new(cx: &mut Context<Self>) -> Self {
166            Self {
167                open: true,
168                trigger_focus: cx.focus_handle(),
169                content_focus: cx.focus_handle(),
170                events: Arc::new(Mutex::new(Vec::new())),
171            }
172        }
173    }
174
175    impl Focusable for Harness {
176        fn focus_handle(&self, _: &App) -> FocusHandle {
177            self.trigger_focus.clone()
178        }
179    }
180
181    impl Render for Harness {
182        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
183            let state = cx.entity();
184            let confirm_events = self.events.clone();
185            let dismiss_events = self.events.clone();
186            let open_events = self.events.clone();
187
188            Combobox::new("combobox")
189                .open(self.open)
190                .focus_handle(&self.trigger_focus)
191                .content_focus_handle(&self.content_focus)
192                .on_confirm(move |_, _| confirm_events.lock().unwrap().push("confirm"))
193                .on_dismiss(move |_, _| dismiss_events.lock().unwrap().push("dismiss"))
194                .on_open_change(move |open, _, cx| {
195                    open_events.lock().unwrap().push("close");
196                    state.update(cx, |state, cx| {
197                        state.open = open;
198                        cx.notify();
199                    });
200                })
201                .child(div().track_focus(&self.content_focus).size(px(20.)))
202        }
203    }
204
205    fn harness(cx: &mut TestAppContext) -> (&mut VisualTestContext, gpui::Entity<Harness>) {
206        cx.update(crate::init);
207        let (state, cx) = cx.add_window_view(|_, cx| Harness::new(cx));
208        cx.update(|window, cx| {
209            let content_focus = state.read(cx).content_focus.clone();
210            content_focus.focus(window, cx);
211            window.draw(cx).clear(cx);
212        });
213        (cx, state)
214    }
215
216    #[gpui::test]
217    fn escape_dismisses_then_closes_and_restores_trigger_focus(cx: &mut TestAppContext) {
218        let (cx, state) = harness(cx);
219        cx.simulate_keystrokes("escape");
220
221        cx.update(|window, cx| {
222            assert!(!state.read(cx).open);
223            assert!(state.read(cx).trigger_focus.is_focused(window));
224            // Dismissal runs before the close request so a caller can still
225            // commit its pending value.
226            assert_eq!(
227                state.read(cx).events.lock().unwrap().as_slice(),
228                &["dismiss", "close"]
229            );
230        });
231    }
232
233    #[gpui::test]
234    fn enter_confirms_without_dismissing_while_open(cx: &mut TestAppContext) {
235        let (cx, state) = harness(cx);
236        cx.simulate_keystrokes("enter");
237
238        cx.update(|_, cx| {
239            assert!(state.read(cx).open);
240            assert_eq!(
241                state.read(cx).events.lock().unwrap().as_slice(),
242                &["confirm"]
243            );
244        });
245    }
246}