Skip to main content

gpui_cn/
command_dialog.rs

1//! A command palette in a dialog over the page, as shadcn's
2//! `CommandDialog`.
3
4use std::rc::Rc;
5
6use gpui_kit::{
7    App, Entity, Focusable as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
8    Styled as _, Subscription, Window,
9};
10
11use crate::{ActiveTheme as _, Command, CommandEvent, CommandState, Dialog, menu::MenuLook};
12
13type OpenChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
14
15/// Closes the dialog when a command is chosen. It lives in the window's
16/// keyed state, so the subscription is made once and the handler is the
17/// latest one the application gave.
18struct Closer {
19    handler: Option<OpenChange>,
20    open: bool,
21    _subscription: Subscription,
22}
23
24/// A [`Command`] palette in a [`Dialog`], as shadcn's `CommandDialog`
25/// and the palettes opened with Cmd+K.
26///
27/// The dialog has no title and no close button: Escape and a press on the
28/// backdrop close it. It sits at the center horizontally and at the
29/// theme's `command_dialog_top` below the top edge, so it rests in the
30/// upper third of the window as Spotlight does, and does not move when
31/// its height changes. It is the theme's `command_dialog_width` wide, and
32/// narrower in a window with no room for it, with the dialog layer's
33/// margin on both sides.
34///
35/// The search field takes focus when the dialog opens, and focus returns
36/// to where it was when it closes. The box is as tall as a palette's
37/// list can be, in every state, so the dialog does not jump while the
38/// number of matches changes; the list scrolls inside it. Choosing a
39/// command closes the dialog after the state reports
40/// [`CommandEvent::Confirmed`].
41///
42/// ```
43/// use gpui_cn::{CommandDialog, CommandState};
44/// use gpui_kit::{Entity, IntoElement};
45///
46/// fn palette(state: &Entity<CommandState>, open: bool) -> impl IntoElement {
47///     CommandDialog::new("palette", state)
48///         .open(open)
49///         .empty("Nothing matches.")
50/// }
51/// ```
52///
53/// The application owns the open state: [`open`](Self::open) sets it, and
54/// [`on_open_change`](Self::on_open_change) reports Escape, a backdrop
55/// press, and a chosen command, which should clear it. The commands, the
56/// query, and the placeholder live in the [`CommandState`]. The id must
57/// be stable across frames.
58#[derive(IntoElement)]
59pub struct CommandDialog {
60    id: gpui_kit::ElementId,
61    state: Entity<CommandState>,
62    open: bool,
63    empty: Option<SharedString>,
64    on_open_change: Option<OpenChange>,
65}
66
67impl CommandDialog {
68    /// A closed palette dialog on `state`, with a stable id.
69    pub fn new(id: impl Into<gpui_kit::ElementId>, state: &Entity<CommandState>) -> Self {
70        Self {
71            id: id.into(),
72            state: state.clone(),
73            open: false,
74            empty: None,
75            on_open_change: None,
76        }
77    }
78
79    /// Shows or hides the dialog.
80    pub fn open(mut self, open: bool) -> Self {
81        self.open = open;
82        self
83    }
84
85    /// The text shown while no command matches. The default is the
86    /// palette's own.
87    pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
88        self.empty = Some(text.into());
89        self
90    }
91
92    /// Called with `false` when Escape, a press on the backdrop, or a
93    /// chosen command asks to close the dialog.
94    pub fn on_open_change(
95        mut self,
96        handler: impl Fn(bool, &mut Window, &mut App) + 'static,
97    ) -> Self {
98        self.on_open_change = Some(Rc::new(handler));
99        self
100    }
101}
102
103impl RenderOnce for CommandDialog {
104    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
105        let child = |name: &'static str| {
106            gpui_kit::ElementId::NamedChild(self.id.clone().into(), name.into())
107        };
108        let state = self.state.clone();
109        let closer = window.use_keyed_state(child("closer"), cx, |window, cx| Closer {
110            handler: None,
111            open: false,
112            _subscription: cx.subscribe_in(
113                &state,
114                window,
115                |this: &mut Closer, _, event: &CommandEvent, window, cx| {
116                    if this.open
117                        && matches!(
118                            event,
119                            CommandEvent::Confirmed(_) | CommandEvent::Submitted(_)
120                        )
121                        && let Some(handler) = this.handler.clone()
122                    {
123                        handler(false, window, cx);
124                    }
125                },
126            ),
127        });
128        let (handler, open) = (self.on_open_change.clone(), self.open);
129        closer.update(cx, |closer, _| {
130            closer.handler = handler;
131            closer.open = open;
132        });
133
134        let theme = cx.theme();
135        let look = MenuLook::of(theme, window.rem_size());
136        let (width, top) = (
137            theme.metrics.command_dialog_width,
138            theme.metrics.command_dialog_top,
139        );
140        let focus = self.state.focus_handle(cx);
141        let mut command = Command::new(child("command"), &self.state)
142            .bordered(false)
143            .w_full()
144            .max_w_full()
145            .h(look.palette_height());
146        if let Some(empty) = self.empty {
147            command = command.empty(empty);
148        }
149        let on_open_change = self.on_open_change;
150        let mut dialog = Dialog::new(self.id.clone())
151            .open(self.open)
152            .show_close_button(false)
153            .top(top)
154            .w(width)
155            .p_0()
156            .overflow_hidden()
157            .track_focus(&focus)
158            .child(command);
159        if let Some(handler) = on_open_change {
160            dialog = dialog.on_open_change(move |open, window, cx| handler(open, window, cx));
161        }
162        dialog
163    }
164}